diff --git a/.agents/skills/kilo-design/reference/ux-writing.md b/.agents/skills/kilo-design/reference/ux-writing.md index 0964053640..debb54e63b 100644 --- a/.agents/skills/kilo-design/reference/ux-writing.md +++ b/.agents/skills/kilo-design/reference/ux-writing.md @@ -89,8 +89,8 @@ expectations: `Provisioning machine. This usually takes 30–60 seconds.` Confirm only for truly irreversible or high-stakes actions. Name the action in both buttons: -- `Delete workspace permanently?` - Primary: `Delete workspace` (destructive variant). +- `Delete workspace permanently?` + Primary: `Delete workspace` (destructive variant). Secondary: `Keep workspace`. ### Accessibility copy diff --git a/.github/workflows/deploy-kiloclaw.yml b/.github/workflows/deploy-kiloclaw.yml index 007dcba3f9..5808a1eb38 100644 --- a/.github/workflows/deploy-kiloclaw.yml +++ b/.github/workflows/deploy-kiloclaw.yml @@ -50,6 +50,7 @@ jobs: # - container/ (COPY container/TOOLS.md → /usr/local/share/kiloclaw/) # - plugins/kiloclaw-customizer/ (COPY plugin package for image install) # - plugins/kilo-chat/ (COPY plugin package for image install) + # - plugins/kiloclaw-morning-briefing/ (COPY plugin package for image install) # - openclaw-pairing-list.js, openclaw-device-pairing-list.js (COPY) # - skills/ (COPY skills/ → /root/clawd/skills/) # @@ -70,16 +71,7 @@ jobs: fi done - CONTENT_HASH=$( - find Dockerfile controller/ container/ plugins/kiloclaw-customizer/ plugins/kilo-chat/ plugins/kiloclaw-morning-briefing/ skills/ \ - openclaw-pairing-list.js openclaw-device-pairing-list.js \ - -type f \ - | sort \ - | xargs sha256sum \ - | sha256sum \ - | cut -d' ' -f1 \ - | cut -c1-12 - ) + CONTENT_HASH="$(scripts/image-content-hash.sh --hash --dockerfile Dockerfile)" if [ -z "$CONTENT_HASH" ] || [ ${#CONTENT_HASH} -ne 12 ]; then echo "::error::Failed to compute valid content hash" @@ -412,6 +404,7 @@ jobs: echo "FLY_IMAGE_DIGEST=${DIGEST}" fi echo "OPENCLAW_VERSION=${OPENCLAW}" + echo "FLY_IMAGE_CONTENT_MODE=production" echo "FLY_IMAGE_CONTENT_HASH=${CONTENT}" echo '```' echo "" diff --git a/apps/mobile/.env b/apps/mobile/.env index 11c9f66e17..345fc7d139 100644 --- a/apps/mobile/.env +++ b/apps/mobile/.env @@ -5,3 +5,6 @@ CLOUD_AGENT_WS_URL=wss://cloud-agent-next.kilosessions.ai SESSION_INGEST_WS_URL=wss://ingest.kilosessions.ai APPSFLYER_DEV_KEY=jnoVs6KzXanpbKrqXckPu9 APPSFLYER_APP_ID=6761193135 +KILO_CHAT_URL=https://chat.kiloapps.io +EVENT_SERVICE_URL=wss://events.kiloapps.io +NOTIFICATIONS_URL=https://notifications.kiloapps.io diff --git a/apps/mobile/.env.local.example b/apps/mobile/.env.local.example new file mode 100644 index 0000000000..2e7552a933 --- /dev/null +++ b/apps/mobile/.env.local.example @@ -0,0 +1,15 @@ +# Client-side env vars are bundled into the app binary and are not secret. +# +# localhost works for local tooling and most simulator flows. When running on a +# physical phone, replace localhost with your development machine's LAN IP. +# On macOS, get the active LAN IP with: +# route -n get default | awk '/interface:/{print $2}' | xargs ipconfig getifaddr +API_BASE_URL=http://localhost:3000 +WEB_BASE_URL=http://localhost:3000 +CLOUD_AGENT_WS_URL=ws://localhost:8794 +SESSION_INGEST_WS_URL=ws://localhost:8800 +APPSFLYER_DEV_KEY=jnoVs6KzXanpbKrqXckPu9 +APPSFLYER_APP_ID=6761193135 +KILO_CHAT_URL=http://localhost:8808 +EVENT_SERVICE_URL=ws://localhost:8809 +NOTIFICATIONS_URL=http://localhost:8804 diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 3899cef255..b84007a8a4 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -23,11 +23,16 @@ "dependencies": { "@expo-google-fonts/jetbrains-mono": "^0.4.1", "@expo/react-native-action-sheet": "^4.1.1", + "@kilocode/event-service": "workspace:*", + "@kilocode/kilo-chat": "workspace:*", + "@kilocode/kilo-chat-hooks": "workspace:*", + "@kilocode/notifications": "workspace:*", "@kilocode/trpc": "workspace:*", "@react-native-community/netinfo": "11.5.2", "@rn-primitives/portal": "^1.3.0", "@rn-primitives/slot": "^1.2.0", "@sentry/react-native": "~7.11.0", + "@shopify/flash-list": "2.0.2", "@tailwindcss/postcss": "^4.2.2", "@tanstack/react-query": "catalog:", "@trpc/client": "catalog:", @@ -49,7 +54,6 @@ "expo-font": "~55.0.6", "expo-haptics": "~55.0.13", "expo-image": "~55.0.8", - "expo-image-manipulator": "~55.0.14", "expo-image-picker": "~55.0.17", "expo-insights": "55.0.15", "expo-linking": "~55.0.11", @@ -78,11 +82,9 @@ "react-native-svg": "15.15.3", "react-native-worklets": "0.7.2", "sonner-native": "^0.23.1", - "stream-chat": "catalog:", - "stream-chat-expo": "^8.13.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.2", - "zod": "catalog:" + "ulid": "3.0.1" }, "devDependencies": { "@sentry/cli": "catalog:", @@ -93,5 +95,10 @@ "typescript": "catalog:", "vitest": "^4.1.0" }, + "dependenciesMeta": { + "@kilocode/kilo-chat-hooks": { + "injected": true + } + }, "private": true } diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx index 0ed7086536..714c088adc 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx @@ -5,5 +5,27 @@ export const unstable_settings = { }; export default function KiloClawLayout() { - return ; + return ( + + + + + + ); } diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/[conversation-id].tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/[conversation-id].tsx new file mode 100644 index 0000000000..320e5e0232 --- /dev/null +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/[conversation-id].tsx @@ -0,0 +1,63 @@ +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useEffect } from 'react'; +import { toast } from 'sonner-native'; + +import { ChatSandboxRouteMounts } from '@/components/kilo-chat/chat-sandbox-route-mounts'; +import { ConversationScreen } from '@/components/kilo-chat/conversation-screen'; +import { + getConversationRouteDecision, + getConversationRouteErrorMessage, + shouldRenderConversationScreen, +} from '@/components/kilo-chat/conversation-route-state'; +import { useConversationDetail } from '@/components/kilo-chat/hooks/use-conversations'; +import { useKiloChatClient } from '@/components/kilo-chat/hooks/use-kilo-chat-client'; +import { chatSandboxPath } from '@/lib/kilo-chat-routes'; + +export default function ChatConversationRoute() { + const params = useLocalSearchParams<{ 'sandbox-id': string; 'conversation-id': string }>(); + const sandboxId = params['sandbox-id']; + const conversationId = params['conversation-id']; + const router = useRouter(); + const client = useKiloChatClient(); + const conversationDetail = useConversationDetail(client, conversationId); + const redirectPath = chatSandboxPath(sandboxId); + const routeDecision = getConversationRouteDecision({ + detail: conversationDetail, + routeSandboxId: sandboxId, + }); + + useEffect(() => { + if (conversationDetail.isError) { + toast.error(getConversationRouteErrorMessage(conversationDetail.error)); + router.replace(redirectPath); + return; + } + if (routeDecision === 'not-found') { + toast.error('Conversation not found'); + router.replace(redirectPath); + } + }, [conversationDetail.error, conversationDetail.isError, redirectPath, routeDecision, router]); + + if ( + !shouldRenderConversationScreen({ + detail: conversationDetail, + routeSandboxId: sandboxId, + }) || + !conversationDetail.data + ) { + return null; + } + + return ( + <> + + + + ); +} diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/index.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/index.tsx new file mode 100644 index 0000000000..aa120b9156 --- /dev/null +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/index.tsx @@ -0,0 +1,19 @@ +import { useLocalSearchParams } from 'expo-router'; + +import { ChatSandboxRouteMounts } from '@/components/kilo-chat/chat-sandbox-route-mounts'; +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 instance = instances?.find(i => i.sandboxId === sandboxId); + const sandboxLabel = + instance?.botName ?? instance?.name ?? instance?.organizationName ?? 'KiloClaw'; + return ( + <> + + + + ); +} diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/instance-picker.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/instance-picker.tsx new file mode 100644 index 0000000000..ceab1bfc55 --- /dev/null +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/instance-picker.tsx @@ -0,0 +1,99 @@ +import * as Haptics from 'expo-haptics'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { Check } from 'lucide-react-native'; +import { Pressable, ScrollView, View } from 'react-native'; + +import { StatusBadge } from '@/components/kiloclaw/status-badge'; +import { QueryError } from '@/components/query-error'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Text } from '@/components/ui/text'; +import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { kiloclawInstanceSwitcherTitle } from '@/lib/kiloclaw-display'; +import { chatSandboxPath } from '@/lib/kilo-chat-routes'; + +export default function InstancePickerScreen() { + const router = useRouter(); + const colors = useThemeColors(); + const { currentId } = useLocalSearchParams<{ currentId: string }>(); + const instancesQuery = useAllKiloClawInstances(); + const { data: instances } = instancesQuery; + + const handleSelect = (sandboxId: string) => { + void Haptics.selectionAsync(); + if (sandboxId === currentId) { + router.back(); + return; + } + router.dismissAll(); + router.push(chatSandboxPath(sandboxId)); + }; + + return ( + + + + Switch Instance + { + router.back(); + }} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel="Done" + className="absolute right-0 rounded-full bg-secondary px-4 py-2 active:opacity-70 will-change-pressable" + > + Done + + + + + {instancesQuery.isPending ? ( + + + + + + ) : null} + {instancesQuery.isError ? ( + { + void instancesQuery.refetch(); + }} + /> + ) : null} + {!instancesQuery.isPending && !instancesQuery.isError + ? (instances ?? []).map(instance => { + const isCurrent = instance.sandboxId === currentId; + const title = kiloclawInstanceSwitcherTitle(instance); + return ( + { + handleSelect(instance.sandboxId); + }} + accessibilityRole="button" + accessibilityLabel={`${title}${isCurrent ? ', current' : ''}`} + > + + + {title} + + + + {instance.organizationName ?? 'Personal'} + + + + + {isCurrent ? : null} + + ); + }) + : null} + + ); +} diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx index e40ecd0330..ee36b6f1ab 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx @@ -1,31 +1,105 @@ import { type Href, useRouter } from 'expo-router'; +import { useCallback, useState } from 'react'; import { View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; import { EmptyStateContent } from '@/components/kiloclaw/empty-state-content'; +import { getKiloClawEntryDecision } from '@/components/kiloclaw/instance-entry-state'; +import { InstanceListScreen } from '@/components/kiloclaw/instance-list-screen'; import { ProfileAvatarButton } from '@/components/profile-avatar-button'; +import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; import { Skeleton } from '@/components/ui/skeleton'; import { useForegroundInvalidateKiloclawState } from '@/lib/hooks/use-foreground-invalidate-kiloclaw-state'; import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; import { useKiloClawMobileOnboardingState } from '@/lib/hooks/use-kiloclaw-queries'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { useUnreadCounts } from '@/lib/hooks/use-unread-counts'; +import { chatSandboxPath } from '@/lib/kilo-chat-routes'; export default function KiloClawTab() { const router = useRouter(); const colors = useThemeColors(); - const { data: instances } = useAllKiloClawInstances(); - const isEmpty = instances?.length === 0; - const onboardingQuery = useKiloClawMobileOnboardingState(isEmpty); + const [manualRefreshing, setManualRefreshing] = useState(false); + const instancesQuery = useAllKiloClawInstances(); + const { data: instances } = instancesQuery; + const { byBadgeBucket: unreadByBadgeBucket } = useUnreadCounts(); + const refetchInstances = instancesQuery.refetch; + const entryDecision = getKiloClawEntryDecision(instances); + const onboardingQuery = useKiloClawMobileOnboardingState(entryDecision.kind === 'empty'); useForegroundInvalidateKiloclawState(); + const showInstanceSkeleton = entryDecision.kind === 'loading' || onboardingQuery.isPending; + + const handleRefresh = useCallback(() => { + void (async () => { + setManualRefreshing(true); + try { + await refetchInstances(); + } finally { + setManualRefreshing(false); + } + })(); + }, [refetchInstances]); + + if (instancesQuery.isError) { + return ( + + } + /> + + { + void instancesQuery.refetch(); + }} + /> + + + ); + } + + if (entryDecision.kind === 'list') { + return ( + { + router.push(chatSandboxPath(sandboxId)); + }} + onSettingsPress={sandboxId => { + router.push(`/(app)/kiloclaw/${sandboxId}/dashboard` as Href); + }} + unreadByBadgeBucket={unreadByBadgeBucket} + onCreate={() => { + router.push('/(app)/onboarding' as Href); + }} + /> + ); + } + return ( - } /> + } + /> - {onboardingQuery.isPending ? ( + {showInstanceSkeleton ? ( - + + + ) : ( diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/rename-conversation.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/rename-conversation.tsx new file mode 100644 index 0000000000..a884d365ab --- /dev/null +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/rename-conversation.tsx @@ -0,0 +1,40 @@ +import { useLocalSearchParams, useRouter } from 'expo-router'; + +import { RenameConversationSheet } from '@/components/kilo-chat/rename-conversation-sheet'; +import { useKiloChatClient } from '@/components/kilo-chat/hooks/use-kilo-chat-client'; +import { useRenameConversation } from '@/components/kilo-chat/hooks/use-conversations'; + +export default function RenameConversationRoute() { + const router = useRouter(); + const client = useKiloChatClient(); + const { sandboxId, conversationId, title } = useLocalSearchParams<{ + sandboxId: string; + conversationId?: string; + title?: string; + }>(); + const renameConversation = useRenameConversation(client); + const initialTitle = typeof title === 'string' ? title : ''; + + return ( + { + router.back(); + }} + onSave={nextTitle => { + if (!conversationId) { + return; + } + renameConversation.mutate( + { conversationId, title: nextTitle, sandboxId }, + { + onSuccess: () => { + router.back(); + }, + } + ); + }} + /> + ); +} diff --git a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx index d6b209ab43..e9f94e0165 100644 --- a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx @@ -1,17 +1,28 @@ import * as Haptics from 'expo-haptics'; -import { type Href, Tabs, useRouter } from 'expo-router'; +import { type Href, Tabs, usePathname, useRouter } from 'expo-router'; import { Bot, House, MessageSquare } from 'lucide-react-native'; -import { useEffect, useState } from 'react'; -import { Platform, View } from 'react-native'; +import { Platform, type TextStyle, View, type ViewStyle } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { BlurBar } from '@/components/ui/blur-bar'; -import { Text } from '@/components/ui/text'; -import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { getLastActiveInstance, loadLastActiveInstance } from '@/lib/last-active-instance'; const ANDROID_TAB_BAR_EXTRA_PADDING = 4; +const TAB_BAR_ITEM_CONTENT_WIDTH = 64; +const TAB_BAR_ICON_STYLE = { + alignItems: 'center', + justifyContent: 'center', + width: TAB_BAR_ITEM_CONTENT_WIDTH, +} satisfies ViewStyle; +const TAB_BAR_LABEL_STYLE = { + fontFamily: 'JetBrainsMono_500Medium', + fontSize: 10, + letterSpacing: 0, + marginTop: 2, + minWidth: TAB_BAR_ITEM_CONTENT_WIDTH, + textAlign: 'center', + textTransform: 'uppercase', +} satisfies TextStyle; export const unstable_settings = { initialRouteName: '(0_home)', @@ -25,34 +36,14 @@ function TabBarBackground() { ); } -function renderTabBarLabel(label: string) { - // Mirrors the "eyebrow" variant (mono/uppercase/10px/muted) without the - // letter-spacing, which would otherwise push the visible glyphs off-center - // beneath the icon since iOS and Android disagree on whether trailing - // letter-spacing is included in the measured text width. - return ( - - {label} - - ); -} - export default function TabsLayout() { + const router = useRouter(); + const pathname = usePathname(); const colors = useThemeColors(); const { bottom } = useSafeAreaInsets(); - const router = useRouter(); - const { data: instances } = useAllKiloClawInstances(); - const [lastActiveHydrated, setLastActiveHydrated] = useState(false); - - useEffect(() => { - void (async () => { - try { - await loadLastActiveInstance(); - } finally { - setLastActiveHydrated(true); - } - })(); - }, []); + const pathParts = pathname.split('/').filter(Boolean); + const hideTabs = + pathParts[0] === 'chat' && pathParts.length === 3 && pathParts[2] !== 'rename-conversation'; return ( ( ), - tabBarLabel: () => renderTabBarLabel('Home'), }} listeners={{ tabPress: () => { @@ -93,30 +88,16 @@ export default function TabsLayout() { name="(1_kiloclaw)" options={{ title: 'KiloClaw', + tabBarLabel: 'KiloClaw', tabBarIcon: ({ color, focused }) => ( ), - tabBarLabel: () => renderTabBarLabel('KiloClaw'), }} listeners={{ - tabPress: e => { + tabPress: event => { void Haptics.selectionAsync(); - // While instances or the persisted last-active id are still loading, - // block the tab switch so the user doesn't briefly land on the - // (1_kiloclaw) empty state, and so we don't redirect into the wrong - // chat before the persisted instance has been hydrated. - if (instances === undefined || !lastActiveHydrated) { - e.preventDefault(); - return; - } - const first = instances[0]; - if (first) { - e.preventDefault(); - const lastId = getLastActiveInstance(); - const target = - lastId && instances.some(i => i.sandboxId === lastId) ? lastId : first.sandboxId; - router.push(`/(app)/chat/${target}` as Href); - } + event.preventDefault(); + router.navigate('/(app)/(tabs)/(1_kiloclaw)' as Href); }, }} /> @@ -124,10 +105,10 @@ export default function TabsLayout() { name="(2_agents)" options={{ title: 'Agents', + tabBarLabel: 'Agents', tabBarIcon: ({ color, focused }) => ( ), - tabBarLabel: () => renderTabBarLabel('Agents'), }} listeners={{ tabPress: () => { diff --git a/apps/mobile/src/app/(app)/_layout.tsx b/apps/mobile/src/app/(app)/_layout.tsx index 60998ebc61..d7dbc544eb 100644 --- a/apps/mobile/src/app/(app)/_layout.tsx +++ b/apps/mobile/src/app/(app)/_layout.tsx @@ -1,75 +1,71 @@ import { Stack } from 'expo-router'; +import { KiloChatPresenceMount } from '@/components/kilo-chat/kilo-chat-presence-mount'; +import { KiloChatProvider } from '@/components/kilo-chat/kilo-chat-provider'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; export default function AppLayout() { const colors = useThemeColors(); return ( - - - - - - - - - - - - + + + + + + + + + + + + + + ); } diff --git a/apps/mobile/src/app/(app)/chat/[instance-id].tsx b/apps/mobile/src/app/(app)/chat/[instance-id].tsx deleted file mode 100644 index 3203d543f3..0000000000 --- a/apps/mobile/src/app/(app)/chat/[instance-id].tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useLocalSearchParams } from 'expo-router'; -import { View } from 'react-native'; - -import { KiloClawChat } from '@/components/kiloclaw/chat'; -import { useInstanceContext } from '@/lib/hooks/use-instance-context'; -import { useKiloClawStatus } from '@/lib/hooks/use-kiloclaw-queries'; - -export default function ChatScreen() { - const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>(); - const { organizationId } = useInstanceContext(instanceId); - const { data: status } = useKiloClawStatus(organizationId); - const isRunning = status?.status === 'running'; - const machineName = status?.name ?? 'Chat'; - - return ( - - - - ); -} diff --git a/apps/mobile/src/app/(app)/chat/instance-picker.tsx b/apps/mobile/src/app/(app)/chat/instance-picker.tsx deleted file mode 100644 index c80568f506..0000000000 --- a/apps/mobile/src/app/(app)/chat/instance-picker.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import * as Haptics from 'expo-haptics'; -import { type Href, useLocalSearchParams, useRouter } from 'expo-router'; -import { Check } from 'lucide-react-native'; -import { Pressable, ScrollView, View } from 'react-native'; - -import { StatusBadge } from '@/components/kiloclaw/status-badge'; -import { Text } from '@/components/ui/text'; -import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; -import { type InstanceStatus } from '@/lib/hooks/use-kiloclaw-queries'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; - -export default function InstancePickerScreen() { - const router = useRouter(); - const colors = useThemeColors(); - const { currentId } = useLocalSearchParams<{ currentId: string }>(); - const { data: instances } = useAllKiloClawInstances(); - - const handleSelect = (sandboxId: string) => { - void Haptics.selectionAsync(); - if (sandboxId === currentId) { - router.back(); - return; - } - router.dismissAll(); - router.push(`/(app)/chat/${sandboxId}` as Href); - }; - - return ( - - - - Switch Instance - { - router.back(); - }} - hitSlop={8} - accessibilityRole="button" - accessibilityLabel="Done" - className="absolute right-0 rounded-full bg-secondary px-4 py-2 active:opacity-70 will-change-pressable" - > - Done - - - - - {(instances ?? []).map(instance => { - const isCurrent = instance.sandboxId === currentId; - return ( - { - handleSelect(instance.sandboxId); - }} - accessibilityRole="button" - accessibilityLabel={`${instance.name ?? instance.sandboxId}${isCurrent ? ', current' : ''}`} - > - - - {instance.name ?? instance.sandboxId} - - - - {isCurrent && } - - ); - })} - - ); -} diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx index 025d16f0ed..4572f8b2eb 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx @@ -1,7 +1,15 @@ import { type Href, useLocalSearchParams, useRouter } from 'expo-router'; import { CreditCard, Newspaper, Pencil } from 'lucide-react-native'; -import { useState } from 'react'; -import { Alert, Linking, Platform, Pressable, ScrollView, View } from 'react-native'; +import { useCallback, useState } from 'react'; +import { + Alert, + Linking, + Platform, + Pressable, + RefreshControl, + ScrollView, + View, +} from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; import { BillingBanner } from '@/components/kiloclaw/billing-banner'; @@ -55,6 +63,38 @@ export default function DashboardScreen() { const isLoading = statusQuery.isPending || (isPersonal && billingQuery.isPending); const [renameVisible, setRenameVisible] = useState(false); + const [manualRefreshing, setManualRefreshing] = useState(false); + const refetchStatus = statusQuery.refetch; + const refetchBilling = billingQuery.refetch; + const refetchServiceDegraded = serviceDegradedQuery.refetch; + const refetchGateway = gatewayQuery.refetch; + const refetchConfig = configQuery.refetch; + + const handleRefresh = useCallback(() => { + void (async () => { + setManualRefreshing(true); + try { + const refreshes = [ + refetchStatus(), + refetchConfig(), + refetchServiceDegraded(), + ...(isRunning ? [refetchGateway()] : []), + ...(isPersonal ? [refetchBilling()] : []), + ]; + await Promise.all(refreshes); + } finally { + setManualRefreshing(false); + } + })(); + }, [ + refetchBilling, + refetchConfig, + refetchGateway, + refetchServiceDegraded, + refetchStatus, + isPersonal, + isRunning, + ]); if (isLoading) { return ( @@ -126,7 +166,19 @@ export default function DashboardScreen() { } /> - + + } + > { if (fontsError) { Sentry.captureException(fontsError); diff --git a/apps/mobile/src/components/agents/markdown-palette.test.ts b/apps/mobile/src/components/agents/markdown-palette.test.ts new file mode 100644 index 0000000000..764048f9e5 --- /dev/null +++ b/apps/mobile/src/components/agents/markdown-palette.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { getPalette } from './markdown-palette'; +import { type ThemeColors } from '@/lib/hooks/use-theme-colors'; + +const colors = { + background: '#FBFAF5', + foreground: '#14130F', + primary: '#4F5A10', + primaryForeground: '#FFFFFF', + secondary: '#F0EEE6', + secondaryForeground: '#14130F', + muted: '#F0EEE6', + mutedForeground: '#7A756B', + destructive: '#C25647', + border: 'rgba(20, 15, 10, 0.09)', + card: '#FFFFFF', + ink2: '#3C382F', + mutedSoft: '#A9A39A', + hairSoft: 'rgba(20, 15, 10, 0.05)', + accentSoft: '#E8F27A', + accentSoftForeground: '#1A1A10', + good: '#2F9A5F', + warn: '#B27214', + agentYuki: '#6B4FD6', + agentWorkclaw: '#4F5A10', + agentCloud: '#2F9A5F', + agentKilocode: '#B27214', + agentCoral: '#C25647', + agentSky: '#2C7FB0', +} satisfies ThemeColors; + +describe('markdown palette', () => { + it('uses white text for user-authored chat bubbles', () => { + const palette = getPalette('user', colors); + + expect(palette.textColor).toBe('#FFFFFF'); + }); +}); diff --git a/apps/mobile/src/components/agents/markdown-palette.ts b/apps/mobile/src/components/agents/markdown-palette.ts index 4f1bba61bf..e2279c8f40 100644 --- a/apps/mobile/src/components/agents/markdown-palette.ts +++ b/apps/mobile/src/components/agents/markdown-palette.ts @@ -33,9 +33,7 @@ function withAlpha(color: string, alpha: number): string { export function getPalette(variant: MarkdownVariant, colors: ThemeColors): MarkdownPalette { if (variant === 'user') { - // User bubbles sit on `accent-soft` (lime); ink-on-lime is the correct - // foreground, and translucent ink produces subtle codespan / divider tints. - const ink = colors.accentSoftForeground; + const ink = '#FFFFFF'; return { textColor: ink, mutedTextColor: withAlpha(ink, 0.7), diff --git a/apps/mobile/src/components/agents/markdown-text.tsx b/apps/mobile/src/components/agents/markdown-text.tsx index f9f39df50c..7381f866d4 100644 --- a/apps/mobile/src/components/agents/markdown-text.tsx +++ b/apps/mobile/src/components/agents/markdown-text.tsx @@ -1,5 +1,6 @@ import { type ReactNode, useMemo } from 'react'; import { + Linking, ScrollView, Text, type TextStyle, @@ -21,6 +22,7 @@ import { type MarkdownTextProps = { value: string; variant?: MarkdownVariant; + selectable?: boolean; }; // The library's default `Renderer` renders code blocks with the `em` text @@ -37,10 +39,24 @@ type MarkdownTextProps = { // instead — readable in chat, and it avoids the Fabric measurement bug. class MarkdownRenderer extends Renderer { private readonly palette: MarkdownPalette; + private readonly selectable: boolean; - constructor(palette: MarkdownPalette) { + constructor(palette: MarkdownPalette, selectable = true) { super(); this.palette = palette; + this.selectable = selectable; + } + + private textNode(children: string | ReactNode[], styles?: TextStyle): ReactNode { + return ( + + {children} + + ); + } + + override heading(text: string | ReactNode[], styles?: TextStyle): ReactNode { + return this.textNode(text, styles); } // eslint-disable-next-line eslint/max-params -- signature fixed by react-native-marked's RendererInterface @@ -53,7 +69,7 @@ class MarkdownRenderer extends Renderer { return ( { + void Linking.openURL(href); + }} + style={styles} + > + {children} + + ); + } + + override strong(children: string | ReactNode[], styles?: TextStyle): ReactNode { + return this.textNode(children, styles); + } + + override em(children: string | ReactNode[], styles?: TextStyle): ReactNode { + return this.textNode(children, styles); + } + + override codespan(text: string, styles?: TextStyle): ReactNode { + return this.textNode(text, styles); + } + + override br(): ReactNode { + return this.textNode('\n', {}); + } + + override del(children: string | ReactNode[], styles?: TextStyle): ReactNode { + return this.textNode(children, styles); + } + + override text(text: string | ReactNode[], styles?: TextStyle): ReactNode { + return this.textNode(text, styles); + } + + override html(text: string | ReactNode[], styles?: TextStyle): ReactNode { + return this.textNode(text, styles); + } + // eslint-disable-next-line eslint/max-params -- signature fixed by react-native-marked's RendererInterface override table( header: ReactNode[][], @@ -170,7 +242,11 @@ function TableCell({ palette, width, hasRightBorder, hasBottomBorder, children } ); } -export function MarkdownText({ value, variant = 'assistant' }: Readonly) { +export function MarkdownText({ + value, + variant = 'assistant', + selectable = true, +}: Readonly) { const colorScheme = useColorScheme(); const colors = useThemeColors(); @@ -178,7 +254,7 @@ export function MarkdownText({ value, variant = 'assistant' }: Readonly 0; const isFirstTime = !hasInstance && !hasAnySession && !instancesError; - const title = isFirstTime ? 'Welcome to Kilo' : buildTimedGreeting(null); + const headerTitle = buildTimedGreeting(null); const handleRefresh = useCallback(() => { void (async () => { @@ -109,14 +111,20 @@ export function HomeScreen() { return ( - } /> + } + /> } > - + {isFirstTime ? : null} {isLoading ? ( @@ -128,7 +136,7 @@ export function HomeScreen() { {renderKiloClawSlot({ instances: instances ?? [], instancesError, - unreadByChannel, + unreadByBadgeBucket, })} {renderSessionsOrPromo({ @@ -151,7 +159,7 @@ export function HomeScreen() { function renderKiloClawSlot(params: { instances: ClawInstance[]; instancesError: boolean; - unreadByChannel: Map; + unreadByBadgeBucket: Map; }) { if (params.instances.length > 0) { return ( @@ -162,7 +170,9 @@ function renderKiloClawSlot(params: { ))} diff --git a/apps/mobile/src/components/home/kiloclaw-card.tsx b/apps/mobile/src/components/home/kiloclaw-card.tsx deleted file mode 100644 index 16599f6e32..0000000000 --- a/apps/mobile/src/components/home/kiloclaw-card.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query'; -import { type Href, useRouter } from 'expo-router'; -import { Pressable, View } from 'react-native'; - -import { isTransitionalStatus, statusLabel, statusTone } from '@/components/kiloclaw/status-badge'; -import { StatusDot } from '@/components/ui/status-dot'; -import { Text } from '@/components/ui/text'; -import { agentColor } from '@/lib/agent-color'; -import { useKiloClawLatestMessage } from '@/lib/hooks/use-kiloclaw-latest-message'; -import { useKiloClawStatus, useKiloClawStatusQueryKey } from '@/lib/hooks/use-kiloclaw-queries'; -import { parseTimestamp } from '@/lib/utils'; - -type KiloClawCardProps = { - instance: { - sandboxId: string; - name: string | null; - organizationId: string | null; - organizationName: string | null; - status: string | null; - }; - unreadCount?: number; -}; - -type CachedStatus = NonNullable['data']>; - -function formatUnreadCount(count: number): string { - return count > 99 ? '99+' : String(count); -} - -function formatMessagePreview( - message: { text: string; isFromMe: boolean }, - botEmoji: string | null -): string { - const text = message.text.length > 0 ? message.text : 'New message'; - if (message.isFromMe) { - return `You: ${text}`; - } - return botEmoji ? `${botEmoji} ${text}` : text; -} - -function formatClockTime(date: Date): string { - const hours = date.getHours(); - const minutes = String(date.getMinutes()).padStart(2, '0'); - const period = hours >= 12 ? 'PM' : 'AM'; - const displayHours = hours % 12 === 0 ? 12 : hours % 12; - return `${String(displayHours)}:${minutes} ${period}`; -} - -function firstLetter(name: string): string { - const trimmed = name.trim(); - return trimmed.length > 0 ? (trimmed[0]?.toUpperCase() ?? 'K') : 'K'; -} - -export function KiloClawCard({ instance, unreadCount = 0 }: Readonly) { - const router = useRouter(); - - // Peek at the latest cached status (non-subscribing) so we can choose the - // poll cadence before subscribing. Falls back to the list's status when - // the status cache is cold. When the live query refreshes below, - // re-render recomputes this and the interval flips. - const queryClient = useQueryClient(); - const statusQueryKey = useKiloClawStatusQueryKey(instance.organizationId); - const cachedStatus = queryClient.getQueryData(statusQueryKey); - const effectiveStatus = cachedStatus?.status ?? instance.status ?? null; - const fastPoll = isTransitionalStatus(effectiveStatus); - - const { data: status } = useKiloClawStatus( - instance.organizationId, - true, - fastPoll ? 5000 : 10_000 - ); - const { data: latest } = useKiloClawLatestMessage(instance.organizationId); - - const botEmoji = status?.botEmoji ?? null; - const displayName = status?.botName ?? instance.name ?? 'KiloClaw'; - const rawStatus = status?.status ?? instance.status ?? 'offline'; - const tone = statusTone(rawStatus); - const label = statusLabel(rawStatus); - const tapDisabled = isTransitionalStatus(rawStatus); - - const hue = agentColor(displayName); - const lastMessageTime = latest ? formatClockTime(parseTimestamp(latest.created_at)) : null; - - const hasUnread = unreadCount > 0; - const accessibilityLabel = hasUnread - ? `Open ${displayName}, ${unreadCount} unread ${unreadCount === 1 ? 'message' : 'messages'}` - : `Open ${displayName}`; - - const handlePress = () => { - router.push(`/(app)/chat/${instance.sandboxId}` as Href); - }; - - return ( - - - - - {botEmoji ? ( - {botEmoji} - ) : ( - - {firstLetter(displayName)} - - )} - - - - - {displayName} - - {lastMessageTime ? ( - - {lastMessageTime} - - ) : null} - - - - {label} - - - {hasUnread ? ( - - - {formatUnreadCount(unreadCount)} - - - ) : null} - - - {latest ? ( - - - {formatMessagePreview(latest, botEmoji)} - - - ) : null} - - ); -} diff --git a/apps/mobile/src/components/kilo-chat/bot-send-state.test.ts b/apps/mobile/src/components/kilo-chat/bot-send-state.test.ts new file mode 100644 index 0000000000..1a6d651d11 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/bot-send-state.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveMobileMessageInputAvailability } from './bot-send-state'; + +const NOW = 1_000_000; + +describe('mobile bot send gate', () => { + it('blocks sends while bot status is unknown', () => { + const state = resolveMobileMessageInputAvailability({ + currentUserId: 'user-1', + instanceStatus: 'running', + presence: undefined, + now: NOW, + pendingMutation: false, + editing: false, + }); + + expect(state.disabled).toBe(true); + expect(state.disabledReason).toBe('Waiting for bot status...'); + }); + + it('blocks sends when the bot is offline or stale', () => { + expect( + resolveMobileMessageInputAvailability({ + currentUserId: 'user-1', + instanceStatus: 'running', + presence: { online: false, lastAt: NOW }, + now: NOW, + pendingMutation: false, + editing: false, + }).disabled + ).toBe(true); + + expect( + resolveMobileMessageInputAvailability({ + currentUserId: 'user-1', + instanceStatus: 'running', + presence: { online: true, lastAt: NOW - 91_000 }, + now: NOW, + pendingMutation: false, + editing: false, + }).disabled + ).toBe(true); + }); + + it('allows sends when the bot is online or recently idle', () => { + expect( + resolveMobileMessageInputAvailability({ + currentUserId: 'user-1', + instanceStatus: 'running', + presence: { online: true, lastAt: NOW - 10_000 }, + now: NOW, + pendingMutation: false, + editing: false, + }).disabled + ).toBe(false); + + expect( + resolveMobileMessageInputAvailability({ + currentUserId: 'user-1', + instanceStatus: 'running', + presence: { online: true, lastAt: NOW - 45_000 }, + now: NOW, + pendingMutation: false, + editing: false, + }).disabled + ).toBe(false); + }); + + it('keeps the composer enabled during pending sends when the bot can receive messages', () => { + const state = resolveMobileMessageInputAvailability({ + currentUserId: 'user-1', + instanceStatus: 'running', + presence: { online: true, lastAt: NOW - 10_000 }, + now: NOW, + pendingMutation: true, + editing: false, + }); + + expect(state.disabled).toBe(false); + expect(state.disabledReason).toBeNull(); + expect(state.submitDisabled).toBe(true); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/bot-send-state.ts b/apps/mobile/src/components/kilo-chat/bot-send-state.ts new file mode 100644 index 0000000000..8739d28852 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/bot-send-state.ts @@ -0,0 +1,94 @@ +type BotPresence = { + online: boolean; + lastAt: number; +}; + +type BotDisplayState = 'online' | 'idle' | 'offline' | 'unknown'; + +type BotDisplay = { + state: BotDisplayState; + label: 'Online' | 'Idle' | 'Offline' | 'Unknown'; +}; + +type MessageInputAvailability = { + botDisplay: BotDisplay; + disabled: boolean; + disabledReason: string | null; + submitDisabled: boolean; +}; + +function computeMobileBotDisplay(params: { + instanceStatus: string | null; + presence: BotPresence | undefined; + now: number; +}): BotDisplay { + if (params.instanceStatus !== 'running') { + return { state: 'offline', label: 'Offline' }; + } + if (!params.presence) { + return { state: 'unknown', label: 'Unknown' }; + } + if (!params.presence.online) { + return { state: 'offline', label: 'Offline' }; + } + const elapsed = params.now - params.presence.lastAt; + if (elapsed > 90_000) { + return { state: 'offline', label: 'Offline' }; + } + if (elapsed > 30_000) { + return { state: 'idle', label: 'Idle' }; + } + return { state: 'online', label: 'Online' }; +} + +export function resolveMobileMessageInputAvailability(params: { + currentUserId: string | null; + instanceStatus: string | null; + presence: BotPresence | undefined; + now: number; + pendingMutation: boolean; + editing: boolean; +}): MessageInputAvailability { + const botDisplay = computeMobileBotDisplay({ + instanceStatus: params.instanceStatus, + presence: params.presence, + now: params.now, + }); + + if (params.currentUserId === null) { + return { + botDisplay, + disabled: true, + disabledReason: 'Loading user...', + submitDisabled: true, + }; + } + + if (params.editing) { + return { + botDisplay, + disabled: false, + disabledReason: null, + submitDisabled: params.pendingMutation, + }; + } + + if (botDisplay.state === 'online' || botDisplay.state === 'idle') { + return { + botDisplay, + disabled: false, + disabledReason: null, + submitDisabled: params.pendingMutation, + }; + } + + return { + botDisplay, + disabled: true, + disabledReason: + botDisplay.state === 'unknown' + ? 'Waiting for bot status...' + : 'Bot is offline. Messages will resume when it reconnects.', + submitDisabled: true, + }; +} diff --git a/apps/mobile/src/components/kilo-chat/chat-sandbox-layout-subscription.test.ts b/apps/mobile/src/components/kilo-chat/chat-sandbox-layout-subscription.test.ts new file mode 100644 index 0000000000..094673c5d6 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/chat-sandbox-layout-subscription.test.ts @@ -0,0 +1,125 @@ +import type * as ReactModule from 'react'; +import { kiloclawInstanceContext } from '@kilocode/event-service'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ChatSandboxInstanceEventSubscriptionMount } from './chat-sandbox-route-mounts'; + +type TestState = { + cleanupCalls: number; + cleanups: (() => void)[]; + sandboxId: string | undefined; + subscribedContexts: string[][]; + unsubscribedContexts: string[][]; +}; + +const testState = vi.hoisted(() => ({ + cleanupCalls: 0, + cleanups: [], + sandboxId: undefined, + subscribedContexts: [], + unsubscribedContexts: [], +})); + +const mocks = vi.hoisted(() => ({ + eventServiceOn: + vi.fn<(eventName: string, handler: (ctx: string, payload: unknown) => void) => () => void>(), + registerConversationListCacheHandlers: vi.fn<() => () => void>(), +})); + +vi.mock('react', async () => { + const actual = await vi.importActual('react'); + return { + ...actual, + useCallback: unknown>(fn: T) => fn, + useEffect: (effect: ReactModule.EffectCallback) => { + const cleanup = effect(); + if (typeof cleanup === 'function') { + testState.cleanups.push(() => { + void cleanup(); + }); + } + }, + useMemo: (factory: () => T) => factory(), + }; +}); + +vi.mock('expo-router', () => ({ + useFocusEffect: (effect: ReactModule.EffectCallback) => { + const cleanup = effect(); + if (typeof cleanup === 'function') { + testState.cleanups.push(() => { + void cleanup(); + }); + } + }, + useLocalSearchParams: () => ({ 'sandbox-id': testState.sandboxId }), +})); + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ + invalidateQueries: vi.fn(), + getQueryData: vi.fn(), + setQueryData: vi.fn(), + }), +})); + +vi.mock('@kilocode/kilo-chat-hooks', () => ({ + botStatusKey: (sandboxId: string | null) => ['kilo-chat', 'bot-status', sandboxId], + conversationsKey: (sandboxId: string | null) => ['kilo-chat', 'conversations', sandboxId], + registerConversationListCacheHandlers: mocks.registerConversationListCacheHandlers, +})); + +vi.mock('@/components/kilo-chat/hooks/use-current-user-id', () => ({ + useCurrentUserId: () => 'user-1', +})); + +vi.mock('@/components/kilo-chat/hooks/use-kilo-chat-client', () => ({ + useEventServiceClient: () => ({ + on: mocks.eventServiceOn, + subscribe: (contexts: string[]) => { + testState.subscribedContexts.push(contexts); + }, + unsubscribe: (contexts: string[]) => { + testState.unsubscribedContexts.push(contexts); + }, + }), + useKiloChatClient: () => ({}), +})); + +vi.mock('@/lib/last-active-instance', () => ({ + setLastActiveInstance: vi.fn(), +})); + +function recordCleanup() { + testState.cleanupCalls += 1; +} + +beforeEach(() => { + testState.cleanupCalls = 0; + testState.cleanups = []; + testState.sandboxId = undefined; + testState.subscribedContexts = []; + testState.unsubscribedContexts = []; + vi.clearAllMocks(); + mocks.eventServiceOn.mockReturnValue(recordCleanup); + mocks.registerConversationListCacheHandlers.mockReturnValue(recordCleanup); +}); + +afterEach(() => { + for (const cleanup of testState.cleanups) { + cleanup(); + } +}); + +describe('ChatSandboxInstanceEventSubscriptionMount', () => { + it('subscribes direct sandbox routes to their instance event context', () => { + testState.sandboxId = 'sandbox-1'; + + const mountInstanceEventSubscription = ChatSandboxInstanceEventSubscriptionMount; + mountInstanceEventSubscription(); + + expect(testState.subscribedContexts).toEqual([[kiloclawInstanceContext('sandbox-1')]]); + expect(mocks.eventServiceOn).toHaveBeenCalledWith('bot.status', expect.any(Function)); + expect(mocks.registerConversationListCacheHandlers).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/chat-sandbox-route-mounts.tsx b/apps/mobile/src/components/kilo-chat/chat-sandbox-route-mounts.tsx new file mode 100644 index 0000000000..fe26993857 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/chat-sandbox-route-mounts.tsx @@ -0,0 +1,34 @@ +import { useFocusEffect, useLocalSearchParams } from 'expo-router'; +import { useCallback } from 'react'; + +import { useInstanceEventSubscription } from '@/components/kilo-chat/hooks/use-instance-event-subscription'; +import { setLastActiveInstance } from '@/lib/last-active-instance'; + +export function ChatSandboxInstanceEventSubscriptionMount() { + const { 'sandbox-id': sandboxId } = useLocalSearchParams<{ 'sandbox-id': string }>(); + useInstanceEventSubscription(sandboxId); + return null; +} + +function ChatSandboxLastActiveInstanceMount() { + const { 'sandbox-id': sandboxId } = useLocalSearchParams<{ 'sandbox-id': string }>(); + + useFocusEffect( + useCallback(() => { + if (sandboxId) { + void setLastActiveInstance(sandboxId); + } + }, [sandboxId]) + ); + + return null; +} + +export function ChatSandboxRouteMounts() { + return ( + <> + + + + ); +} diff --git a/apps/mobile/src/components/kilo-chat/conversation-header.tsx b/apps/mobile/src/components/kilo-chat/conversation-header.tsx new file mode 100644 index 0000000000..1524228f2f --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/conversation-header.tsx @@ -0,0 +1,53 @@ +import { MoreVertical, Shuffle } from 'lucide-react-native'; +import { Pressable, View } from 'react-native'; + +import { ScreenHeader } from '@/components/screen-header'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +type Props = { + title: string; + subtitle?: string; + canSwitchInstance?: boolean; + onSwitchInstance?: () => void; + onOpenOptions?: () => void; +}; + +export function ConversationHeader({ + title, + subtitle, + canSwitchInstance, + onSwitchInstance, + onOpenOptions, +}: Props) { + const colors = useThemeColors(); + return ( + + {canSwitchInstance ? ( + + + + ) : null} + {onOpenOptions ? ( + + + + ) : null} + + } + /> + ); +} diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-groups.test.ts b/apps/mobile/src/components/kilo-chat/conversation-list-groups.test.ts new file mode 100644 index 0000000000..987e3a1d21 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/conversation-list-groups.test.ts @@ -0,0 +1,64 @@ +import { type ConversationListItem } from '@kilocode/kilo-chat'; +import { describe, expect, it } from 'vitest'; + +import { groupConversationsByActivity } from './conversation-list-groups'; + +function conversation( + conversationId: string, + timestamp: number, + overrides: Partial = {} +): ConversationListItem { + return { + conversationId, + title: conversationId, + lastActivityAt: timestamp, + lastReadAt: null, + joinedAt: timestamp - 1000, + ...overrides, + }; +} + +describe('groupConversationsByActivity', () => { + it('groups conversations by local activity day', () => { + const todayStart = new Date(2026, 4, 4).getTime(); + const nowMs = todayStart + 12 * 60 * 60 * 1000; + const yesterday = todayStart - 60 * 60 * 1000; + const thisWeek = todayStart - 3 * 24 * 60 * 60 * 1000; + const older = todayStart - 8 * 24 * 60 * 60 * 1000; + + expect( + groupConversationsByActivity( + [ + conversation('today', nowMs), + conversation('yesterday', yesterday), + conversation('this-week', thisWeek), + conversation('older', older), + ], + nowMs + ) + ).toEqual([ + { label: 'Today', items: [conversation('today', nowMs)] }, + { label: 'Yesterday', items: [conversation('yesterday', yesterday)] }, + { label: 'This Week', items: [conversation('this-week', thisWeek)] }, + { label: 'Older', items: [conversation('older', older)] }, + ]); + }); + + it('uses joined time when last activity is missing', () => { + const todayStart = new Date(2026, 4, 4).getTime(); + const nowMs = todayStart + 12 * 60 * 60 * 1000; + const joinedAt = todayStart - 2 * 24 * 60 * 60 * 1000; + + expect( + groupConversationsByActivity( + [conversation('joined-only', nowMs, { lastActivityAt: null, joinedAt })], + nowMs + ) + ).toEqual([ + { + label: 'This Week', + items: [conversation('joined-only', nowMs, { lastActivityAt: null, joinedAt })], + }, + ]); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-groups.ts b/apps/mobile/src/components/kilo-chat/conversation-list-groups.ts new file mode 100644 index 0000000000..100c16977d --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/conversation-list-groups.ts @@ -0,0 +1,54 @@ +import { type ConversationListItem } from '@kilocode/kilo-chat'; + +type ConversationListGroupLabel = 'Today' | 'Yesterday' | 'This Week' | 'Older'; + +type ConversationListGroup = { + label: ConversationListGroupLabel; + items: ConversationListItem[]; +}; + +const DAY_MS = 24 * 60 * 60 * 1000; +const GROUP_LABELS: readonly ConversationListGroupLabel[] = [ + 'Today', + 'Yesterday', + 'This Week', + 'Older', +]; + +function conversationTimestamp(conversation: ConversationListItem): number { + return conversation.lastActivityAt ?? conversation.joinedAt; +} + +export function groupConversationsByActivity( + conversations: ConversationListItem[], + nowMs: number +): ConversationListGroup[] { + const now = new Date(nowMs); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const yesterdayStart = todayStart - DAY_MS; + const weekStart = todayStart - 6 * DAY_MS; + const groups: Record = { + Today: [], + Yesterday: [], + 'This Week': [], + Older: [], + }; + + for (const conversation of conversations) { + const timestamp = conversationTimestamp(conversation); + if (timestamp >= todayStart) { + groups.Today.push(conversation); + } else if (timestamp >= yesterdayStart) { + groups.Yesterday.push(conversation); + } else if (timestamp >= weekStart) { + groups['This Week'].push(conversation); + } else { + groups.Older.push(conversation); + } + } + + return GROUP_LABELS.filter(label => groups[label].length > 0).map(label => ({ + label, + items: groups[label], + })); +} diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-screen.test.ts b/apps/mobile/src/components/kilo-chat/conversation-list-screen.test.ts new file mode 100644 index 0000000000..cfe93a77f3 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { getConversationListContentState } from './conversation-list-state'; + +describe('getConversationListContentState', () => { + it('keeps the empty conversation CTA out of pending and error states', () => { + expect( + getConversationListContentState({ isPending: true, isError: false, hasData: false }) + ).toBe('loading'); + + expect( + getConversationListContentState({ isPending: false, isError: true, hasData: false }) + ).toBe('error'); + }); + + it('allows the empty conversation CTA only after a successful empty response', () => { + expect( + getConversationListContentState({ isPending: false, isError: false, hasData: true }) + ).toBe('ready'); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx new file mode 100644 index 0000000000..e66ea89223 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx @@ -0,0 +1,287 @@ +import { FlashList } from '@shopify/flash-list'; +import * as Haptics from 'expo-haptics'; +import { type Href, useRouter } from 'expo-router'; +import { Plus, Settings2 } from 'lucide-react-native'; +import { useCallback, useMemo, useState } from 'react'; +import { ActivityIndicator, Pressable, RefreshControl, View, type ViewStyle } from 'react-native'; +import Animated, { FadeIn } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { QueryError } from '@/components/query-error'; +import { ProfileAvatarButton } from '@/components/profile-avatar-button'; +import { ScreenHeader } from '@/components/screen-header'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { chatConversationPath } from '@/lib/kilo-chat-routes'; + +import { EmptyConversationList } from './empty-conversation-list'; +import { groupConversationsByActivity } from './conversation-list-groups'; +import { getConversationListContentState } from './conversation-list-state'; +import { ConversationRow } from './conversation-row'; +import { useKiloChatClient } from './hooks/use-kilo-chat-client'; +import { + useConversations, + useCreateConversation, + useLeaveConversation, +} from './hooks/use-conversations'; +import { useInstancePresence } from './hooks/use-instance-presence'; +import { useNowTicker } from './hooks/use-now-ticker'; + +type Props = { + sandboxId: string; + sandboxLabel: string; +}; + +type ConversationItem = { + kind: 'conversation'; + conversation: NonNullable['data']>['conversations'][number]; +}; + +type ConversationHeaderItem = { + kind: 'header'; + label: string; +}; + +type ConversationListEntry = ConversationHeaderItem | ConversationItem; + +const listStyle = { flex: 1 } satisfies ViewStyle; +const TAB_BAR_FAB_CLEARANCE = 72; +const FAB_SIZE = 56; +const FAB_MARGIN = 16; + +function ConversationListSkeleton({ showHeader }: Readonly<{ showHeader?: boolean }>) { + return ( + + {showHeader ? : null} + {[0, 1, 2, 3].map(i => ( + + + + + + + + + ))} + + ); +} + +function flattenConversationGroups( + conversations: NonNullable['data']>['conversations'], + nowMs: number +): ConversationListEntry[] { + const entries: ConversationListEntry[] = []; + for (const group of groupConversationsByActivity(conversations, nowMs)) { + entries.push({ kind: 'header', label: group.label }); + for (const conversation of group.items) { + entries.push({ kind: 'conversation', conversation }); + } + } + return entries; +} + +export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { + const router = useRouter(); + const colors = useThemeColors(); + const { bottom } = useSafeAreaInsets(); + const client = useKiloChatClient(); + const listQuery = useConversations(client, sandboxId); + const createConversation = useCreateConversation(client); + const leaveConversation = useLeaveConversation(client); + const now = useNowTicker(60_000); + const [manualRefreshing, setManualRefreshing] = useState(false); + + const hasNextPage = listQuery.hasNextPage; + const isFetchingNextPage = listQuery.isFetchingNextPage; + const fetchNextPage = listQuery.fetchNextPage; + const refetchConversations = listQuery.refetch; + const listContentContainerStyle = useMemo( + () => + ({ + flexGrow: 1, + paddingBottom: Math.max(bottom, 16) + TAB_BAR_FAB_CLEARANCE + FAB_SIZE + FAB_MARGIN, + }) satisfies ViewStyle, + [bottom] + ); + const createButtonStyle = useMemo( + () => + ({ + bottom: Math.max(bottom, 16) + TAB_BAR_FAB_CLEARANCE, + right: 20, + }) satisfies ViewStyle, + [bottom] + ); + + useInstancePresence(sandboxId); + + function handleRowPress(conversationId: string) { + void Haptics.selectionAsync(); + router.push(chatConversationPath(sandboxId, conversationId)); + } + + function handleCreateAndNavigate() { + void Haptics.selectionAsync(); + createConversation.mutate( + { sandboxId }, + { + onSuccess: result => { + router.push(chatConversationPath(sandboxId, result.conversationId)); + }, + } + ); + } + + function handleOpenSettings() { + void Haptics.selectionAsync(); + router.push(`/(app)/kiloclaw/${sandboxId}/dashboard` as Href); + } + + function handleLeave(conversationId: string) { + leaveConversation.mutate({ conversationId, sandboxId }); + } + + const fetchMoreConversations = useCallback(() => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage({ cancelRefetch: false }); + } + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + + const handleRefresh = useCallback(() => { + void (async () => { + setManualRefreshing(true); + try { + await refetchConversations(); + } finally { + setManualRefreshing(false); + } + })(); + }, [refetchConversations]); + + const contentState = getConversationListContentState({ + isPending: listQuery.isPending, + isError: listQuery.isError, + hasData: listQuery.data !== undefined, + }); + + if (contentState === 'loading') { + return ( + + + + + + + ); + } + + if (contentState === 'error') { + return ( + + + + { + void listQuery.refetch(); + }} + /> + + + ); + } + + const conversations = listQuery.data?.conversations ?? []; + const entries = flattenConversationGroups(conversations, now); + + return ( + + + + + + + + } + /> + + + entry.kind === 'header' ? `header:${entry.label}` : entry.conversation.conversationId + } + renderItem={({ item }) => + item.kind === 'header' ? ( + + {item.label} + + ) : ( + + + + ) + } + ListEmptyComponent={ + + } + ListFooterComponent={ + isFetchingNextPage ? ( + + + + ) : null + } + onEndReached={fetchMoreConversations} + onEndReachedThreshold={0.5} + refreshControl={ + + } + /> + + + {createConversation.isPending ? ( + + ) : ( + + )} + + + ); +} diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-state.ts b/apps/mobile/src/components/kilo-chat/conversation-list-state.ts new file mode 100644 index 0000000000..4d5386019b --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/conversation-list-state.ts @@ -0,0 +1,22 @@ +type ConversationListContentState = 'loading' | 'error' | 'ready'; + +export function getConversationListContentState({ + isPending, + isError, + hasData, +}: { + isPending: boolean; + isError: boolean; + hasData: boolean; +}): ConversationListContentState { + if (isPending) { + return 'loading'; + } + if (isError) { + return 'error'; + } + if (!hasData) { + return 'loading'; + } + return 'ready'; +} diff --git a/apps/mobile/src/components/kilo-chat/conversation-route-state.test.ts b/apps/mobile/src/components/kilo-chat/conversation-route-state.test.ts new file mode 100644 index 0000000000..583911090a --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/conversation-route-state.test.ts @@ -0,0 +1,71 @@ +import { KiloChatApiError } from '@kilocode/kilo-chat'; +import { describe, expect, it } from 'vitest'; + +import { + getConversationRouteDecision, + getConversationRouteErrorMessage, + shouldRenderConversationScreen, +} from './conversation-route-state'; + +describe('getConversationRouteErrorMessage', () => { + it('uses the not-found message for forbidden conversation detail errors', () => { + expect(getConversationRouteErrorMessage(new KiloChatApiError(403, {}))).toBe( + 'Conversation not found' + ); + }); + + it('uses a generic message for non-API load failures', () => { + expect(getConversationRouteErrorMessage(new Error('network down'))).toBe( + 'Failed to load conversation' + ); + }); +}); + +describe('shouldRenderConversationScreen', () => { + it('does not render while the conversation detail is loading', () => { + expect( + shouldRenderConversationScreen({ + detail: { data: undefined, isError: false }, + routeSandboxId: 'sandbox-1', + }) + ).toBe(false); + }); + + it('renders after conversation detail loads successfully', () => { + expect( + shouldRenderConversationScreen({ + detail: { + data: { + title: 'Kilo Chat', + members: [ + { id: 'user-1', kind: 'user' }, + { id: 'bot:kiloclaw:sandbox-1', kind: 'bot' }, + ], + }, + isError: false, + }, + routeSandboxId: 'sandbox-1', + }) + ).toBe(true); + }); +}); + +describe('getConversationRouteDecision', () => { + it('rejects conversations that belong to a different sandbox route', () => { + expect( + getConversationRouteDecision({ + detail: { + data: { + title: 'Kilo Chat', + members: [ + { id: 'user-1', kind: 'user' }, + { id: 'bot:kiloclaw:sandbox-b', kind: 'bot' }, + ], + }, + isError: false, + }, + routeSandboxId: 'sandbox-a', + }) + ).toBe('not-found'); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/conversation-route-state.ts b/apps/mobile/src/components/kilo-chat/conversation-route-state.ts new file mode 100644 index 0000000000..26d1d6f1c1 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/conversation-route-state.ts @@ -0,0 +1,49 @@ +import { + type ConversationMember, + conversationSandboxIdFromMembers, + KiloChatApiError, +} from '@kilocode/kilo-chat'; + +type ConversationRouteDetailState = { + data: { title: string | null; members: ConversationMember[] } | null | undefined; + isError: boolean; +}; + +type ConversationRouteDecision = 'pending' | 'ready' | 'error' | 'not-found'; + +export function getConversationRouteErrorMessage(error: unknown): string { + const status = error instanceof KiloChatApiError ? error.status : undefined; + if (status === 400 || status === 403 || status === 404) { + return 'Conversation not found'; + } + return 'Failed to load conversation'; +} + +export function getConversationRouteDecision({ + detail, + routeSandboxId, +}: { + detail: ConversationRouteDetailState; + routeSandboxId: string; +}): ConversationRouteDecision { + if (detail.isError) { + return 'error'; + } + if (detail.data === null || detail.data === undefined) { + return 'pending'; + } + if (conversationSandboxIdFromMembers(detail.data.members) !== routeSandboxId) { + return 'not-found'; + } + return 'ready'; +} + +export function shouldRenderConversationScreen({ + detail, + routeSandboxId, +}: { + detail: ConversationRouteDetailState; + routeSandboxId: string; +}): boolean { + return getConversationRouteDecision({ detail, routeSandboxId }) === 'ready'; +} diff --git a/apps/mobile/src/components/kilo-chat/conversation-row.tsx b/apps/mobile/src/components/kilo-chat/conversation-row.tsx new file mode 100644 index 0000000000..3b8b843568 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/conversation-row.tsx @@ -0,0 +1,133 @@ +import { useActionSheet } from '@expo/react-native-action-sheet'; +import { CONVERSATION_TITLE_MAX_CHARS, type ConversationListItem } from '@kilocode/kilo-chat'; +import * as Haptics from 'expo-haptics'; +import { useRouter } from 'expo-router'; +import { MessageSquare, MoreVertical } from 'lucide-react-native'; +import { Alert, Pressable, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { chatRenameConversationPath } from '@/lib/kilo-chat-routes'; +import { timeAgo } from '@/lib/utils'; + +type ConversationRowProps = { + conversation: ConversationListItem; + sandboxId: string; + onPress: (conversationId: string) => void; + onLeave: (conversationId: string) => void; +}; + +function conversationTitle(conversation: ConversationListItem): string { + return conversation.title ?? 'Untitled conversation'; +} + +function conversationTimestamp(conversation: ConversationListItem): number { + return conversation.lastActivityAt ?? conversation.joinedAt; +} + +function hasUnread(conversation: ConversationListItem): boolean { + return ( + conversation.lastActivityAt !== null && + (conversation.lastReadAt === null || conversation.lastReadAt < conversation.lastActivityAt) + ); +} + +export function ConversationRow({ + conversation, + sandboxId, + onPress, + onLeave, +}: Readonly) { + const router = useRouter(); + const colors = useThemeColors(); + const { bottom } = useSafeAreaInsets(); + const { showActionSheetWithOptions } = useActionSheet(); + const title = conversationTitle(conversation); + + function openRenameSheet() { + const params = new URLSearchParams({ + conversationId: conversation.conversationId, + title: (conversation.title ?? '').slice(0, CONVERSATION_TITLE_MAX_CHARS), + }); + router.push(chatRenameConversationPath(sandboxId, params)); + } + + function confirmLeave() { + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + Alert.alert('Leave conversation?', 'This removes it from your list.', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Leave', + style: 'destructive', + onPress: () => { + onLeave(conversation.conversationId); + }, + }, + ]); + } + + function openActions() { + void Haptics.selectionAsync(); + showActionSheetWithOptions( + { + title: title, + options: ['Rename', 'Leave', 'Cancel'], + cancelButtonIndex: 2, + destructiveButtonIndex: 1, + containerStyle: { paddingBottom: bottom }, + }, + index => { + if (index === 0) { + openRenameSheet(); + } else if (index === 1) { + confirmLeave(); + } + } + ); + } + + return ( + { + onPress(conversation.conversationId); + }} + onLongPress={openActions} + > + + + + + + + {title} + + + + {hasUnread(conversation) ? ( + + ) : null} + + {timeAgo(new Date(conversationTimestamp(conversation)))} + + + + + + + + ); +} diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx new file mode 100644 index 0000000000..6f1453f3d9 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -0,0 +1,623 @@ +/* eslint-disable max-lines */ +import { useActionSheet } from '@expo/react-native-action-sheet'; +import * as Clipboard from 'expo-clipboard'; +import * as Haptics from 'expo-haptics'; +import { + attemptMarkCurrentConversationRead, + clearMarkReadRetry, + clearPendingAction, + createMarkReadRetryState, + createMarkReadState, + latestMarkReadMessageId, + type PendingAction, + tryStartPendingAction, + useAddReaction, + useBotStatus, + useDeleteMessage, + useEditMessage, + useExecuteAction, + useRemoveReaction, +} from '@kilocode/kilo-chat-hooks'; +import { + buildMessageActionAvailability, + contentBlocksToText, + type ConversationDetailResponse, + type ExecApprovalDecision, + formatKiloChatError, + type Message, +} from '@kilocode/kilo-chat'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Alert, KeyboardAvoidingView, Platform, View } from 'react-native'; +import { useFocusEffect, useRouter } from 'expo-router'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { toast } from 'sonner-native'; + +import { QueryError } from '@/components/query-error'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ConversationHeader } from './conversation-header'; +import { resolveMobileMessageInputAvailability } from './bot-send-state'; +import { executeActionWithMobileFeedback } from './execute-action-feedback'; +import { buildMessageActionSheetOptions, getSelectedMessageAction } from './message-actions'; +import { MessageInput } from './message-input'; +import { type MessageInputSubmitControls } from './message-input-state'; +import { MessageList } from './message-list'; +import { MessageReactionPickerSheet } from './message-reaction-picker-sheet'; +import { + buildSendMessageVariables, + canCopyMessage, + canToggleReaction, + createSendMessageClientId, +} from './message-presentation'; +import { getMessageHistoryContentState } from './message-history-state'; +import { useConversationPresence } from './hooks/use-conversation-presence'; +import { useConversationEventSubscription } from './hooks/use-conversation-event-subscription'; +import { useLeaveConversation } from './hooks/use-conversations'; +import { useMobileTypingState, useTypingSender } from './hooks/use-typing'; +import { useKiloChatClient } from './hooks/use-kilo-chat-client'; +import { useAppActiveAndFocused } from './hooks/use-app-active-and-focused'; +import { useMarkRead } from './hooks/use-mark-read'; +import { useMessageCacheUpdater, useMessages, useSendMessage } from './hooks/use-messages'; +import { useNowTicker } from './hooks/use-now-ticker'; +import { useCurrentUserId } from './hooks/use-current-user-id'; +import { useAllKiloClawInstances, useInstanceContext } from '@/lib/hooks/use-instance-context'; +import { useKiloClawStatus } from '@/lib/hooks/use-kiloclaw-queries'; +import { kiloclawConversationEyebrow } from '@/lib/kiloclaw-display'; +import { + chatInstancePickerPath, + chatRenameConversationPath, + chatSandboxPath, +} from '@/lib/kilo-chat-routes'; +import { setActiveChatLocation } from '@/lib/notifications'; + +type Props = { + sandboxId: string; + conversationId: string; + conversationTitle: string; + conversationRenameTitle: string; + conversationMembers: ConversationDetailResponse['members']; +}; + +function editableText(message: Message): string { + return message.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('\n'); +} + +function MessageHistorySkeleton() { + return ( + + + + + + ); +} + +export function ConversationScreen({ + sandboxId, + conversationId, + conversationTitle, + conversationRenameTitle, + conversationMembers, +}: Props) { + const client = useKiloChatClient(); + const router = useRouter(); + const currentUserId = useCurrentUserId(); + const { showActionSheetWithOptions } = useActionSheet(); + const { bottom } = useSafeAreaInsets(); + const [editingMessage, setEditingMessage] = useState(null); + const [replyingTo, setReplyingTo] = useState(null); + const [reactionPickerMessage, setReactionPickerMessage] = useState(null); + const [recentReactions, setRecentReactions] = useState([]); + const [pendingAction, setPendingAction] = useState(null); + const [scrollToNewestRequest, setScrollToNewestRequest] = useState(0); + const pendingActionRef = useRef(null); + const instanceContext = useInstanceContext(sandboxId); + const instanceStatusQuery = useKiloClawStatus( + instanceContext.organizationId, + instanceContext.isResolved + ); + const instanceStatus = instanceStatusQuery.data?.status ?? null; + const botStatus = useBotStatus(client, sandboxId); + const botPresence = botStatus ? { online: botStatus.online, lastAt: botStatus.at } : undefined; + const now = useNowTicker(10_000); + + const messagesQuery = useMessages(client, conversationId); + const messageHistoryState = getMessageHistoryContentState({ + isPending: messagesQuery.isPending, + isError: messagesQuery.isError, + hasData: messagesQuery.data !== undefined, + }); + const hasInitialMessages = messageHistoryState === 'ready'; + const messages = hasInitialMessages ? (messagesQuery.data?.messages ?? []) : []; + const latestMessageId = latestMarkReadMessageId(messages); + const fetchOlder = useCallback(() => { + if (messagesQuery.hasNextPage && !messagesQuery.isFetchingNextPage) { + void messagesQuery.fetchNextPage(); + } + }, [messagesQuery]); + + const sendMutation = useSendMessage(client, conversationId, currentUserId); + const leaveConversation = useLeaveConversation(client); + const editMessage = useEditMessage(client, conversationId); + const deleteMessage = useDeleteMessage(client, conversationId); + const executeAction = useExecuteAction(client, conversationId, currentUserId); + const addReaction = useAddReaction(client, conversationId, currentUserId); + const removeReaction = useRemoveReaction(client, conversationId, currentUserId); + const { typingMembers, clearTypingForMember } = useMobileTypingState({ + client, + currentUserId, + sandboxId, + conversationId, + }); + const sendTyping = useTypingSender(client, conversationId); + const editingText = useMemo( + () => (editingMessage ? editableText(editingMessage) : ''), + [editingMessage] + ); + const inputAvailability = resolveMobileMessageInputAvailability({ + currentUserId, + instanceStatus, + presence: botPresence, + now, + pendingMutation: sendMutation.isPending || editMessage.isPending, + editing: editingMessage !== null, + }); + const { data: instances } = useAllKiloClawInstances(); + const currentInstance = instances?.find(instance => instance.sandboxId === sandboxId); + const canSwitchInstance = (instances?.length ?? 0) > 1; + const instanceLabel = kiloclawConversationEyebrow(currentInstance); + + const handleSwitchInstance = useCallback(() => { + router.push(chatInstancePickerPath(sandboxId)); + }, [router, sandboxId]); + + const handleOpenConversationOptions = useCallback(() => { + void Haptics.selectionAsync(); + showActionSheetWithOptions( + { + title: conversationTitle, + options: ['Rename', 'Leave', 'Cancel'], + cancelButtonIndex: 2, + destructiveButtonIndex: 1, + containerStyle: { paddingBottom: bottom }, + }, + index => { + if (index === 0) { + const params = new URLSearchParams({ conversationId, title: conversationRenameTitle }); + router.push(chatRenameConversationPath(sandboxId, params)); + return; + } + if (index === 1) { + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + Alert.alert('Leave conversation?', 'This removes it from your list.', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Leave', + style: 'destructive', + onPress: () => { + leaveConversation.mutate( + { conversationId, sandboxId }, + { + onSuccess: () => { + router.replace(chatSandboxPath(sandboxId)); + }, + } + ); + }, + }, + ]); + } + } + ); + }, [ + bottom, + conversationId, + conversationRenameTitle, + conversationTitle, + leaveConversation, + router, + sandboxId, + showActionSheetWithOptions, + ]); + const handleSend = useCallback( + (text: string, inReplyToMessageId?: string, controls?: MessageInputSubmitControls) => { + if (!editingMessage && inputAvailability.disabled) { + return; + } + if (editingMessage) { + editMessage.mutate( + { + messageId: editingMessage.id, + conversationId, + content: [{ type: 'text', text }], + timestamp: Date.now(), + }, + { + onSuccess: () => { + controls?.clearDraft(); + setEditingMessage(null); + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + }, + onError: err => { + toast.error(formatKiloChatError(err, 'Failed to edit message')); + }, + } + ); + return; + } + sendMutation.mutate( + buildSendMessageVariables({ + conversationId, + text, + clientId: createSendMessageClientId(), + inReplyToMessageId, + }), + { + onSuccess: () => { + if (controls?.clearDraft() ?? false) { + setReplyingTo(null); + } + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + }, + onError: err => { + toast.error(formatKiloChatError(err, 'Failed to send message')); + }, + } + ); + setScrollToNewestRequest(request => request + 1); + }, + [conversationId, editMessage, editingMessage, inputAvailability.disabled, sendMutation] + ); + const handleReactionPress = useCallback( + (message: Message, emoji: string) => { + if (!currentUserId || !canToggleReaction(message, currentUserId)) { + return; + } + const hasReacted = + message.reactions.find(r => r.emoji === emoji)?.memberIds.includes(currentUserId) ?? false; + if (hasReacted) { + removeReaction.mutate( + { messageId: message.id, emoji }, + { + onError: err => { + toast.error(formatKiloChatError(err, 'Failed to remove reaction')); + }, + } + ); + } else { + addReaction.mutate( + { messageId: message.id, emoji }, + { + onError: err => { + toast.error(formatKiloChatError(err, 'Failed to add reaction')); + }, + } + ); + } + setRecentReactions(previous => [emoji, ...previous.filter(reaction => reaction !== emoji)]); + void Haptics.selectionAsync(); + }, + [addReaction, currentUserId, removeReaction] + ); + const handleCopyMessage = useCallback(async (message: Message) => { + try { + await Clipboard.setStringAsync(contentBlocksToText(message.content)); + toast.success('Copied'); + } catch { + toast.error('Failed to copy'); + } + }, []); + const handleExecuteAction = useCallback( + (message: Message, groupId: string, value: ExecApprovalDecision) => { + const nextPendingAction = { messageId: message.id, groupId }; + if (!tryStartPendingAction(pendingActionRef, nextPendingAction)) { + return; + } + setPendingAction(pendingActionRef.current); + executeActionWithMobileFeedback({ + executeAction, + message, + groupId, + value, + onSettled: () => { + clearPendingAction(pendingActionRef, nextPendingAction); + setPendingAction(pendingActionRef.current); + }, + }); + }, + [executeAction] + ); + const handleLongPressMessage = useCallback( + (message: Message) => { + const isOwnMessage = currentUserId !== null && message.senderId === currentUserId; + const actionAvailability = buildMessageActionAvailability(message, isOwnMessage); + const isPendingMessage = message.id.startsWith('pending-'); + const actionSheet = buildMessageActionSheetOptions({ + canReact: currentUserId !== null && actionAvailability.canReact, + canReply: actionAvailability.canReply, + canCopy: canCopyMessage(message), + canEdit: actionAvailability.canEdit, + canDelete: actionAvailability.canDelete, + isPendingMessage, + }); + showActionSheetWithOptions( + { + options: actionSheet.options, + cancelButtonIndex: actionSheet.cancelButtonIndex, + destructiveButtonIndex: actionSheet.destructiveButtonIndex, + title: 'Message actions', + containerStyle: { paddingBottom: bottom }, + }, + index => { + const selectedAction = getSelectedMessageAction(actionSheet, index); + if (!selectedAction) { + return; + } + + if (selectedAction.kind === 'reaction') { + handleReactionPress(message, selectedAction.emoji); + return; + } + if (selectedAction.kind === 'more-reactions') { + setReactionPickerMessage(message); + return; + } + if (selectedAction.kind === 'copy') { + void handleCopyMessage(message); + return; + } + if (selectedAction.kind === 'reply') { + setEditingMessage(null); + setReplyingTo(message); + return; + } + if (selectedAction.kind === 'edit') { + setReplyingTo(null); + setEditingMessage(message); + return; + } + + Alert.alert('Delete message?', 'This will remove the message from the conversation.', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => { + deleteMessage.mutate( + { messageId: message.id, conversationId }, + { + onError: err => { + toast.error(formatKiloChatError(err, 'Failed to delete message')); + }, + } + ); + }, + }, + ]); + } + ); + }, + [ + bottom, + conversationId, + currentUserId, + deleteMessage, + handleCopyMessage, + handleReactionPress, + showActionSheetWithOptions, + ] + ); + const handleSwipeReplyMessage = useCallback( + (message: Message) => { + const isOwnMessage = currentUserId !== null && message.senderId === currentUserId; + const actionAvailability = buildMessageActionAvailability(message, isOwnMessage); + if (!actionAvailability.canReply) { + return; + } + setEditingMessage(null); + setReplyingTo(message); + void Haptics.selectionAsync(); + }, + [currentUserId] + ); + + useConversationPresence(sandboxId, conversationId); + useConversationEventSubscription(sandboxId, conversationId); + const handleActionFailed = useCallback(() => { + toast.error("Couldn't reach the bot — please try again"); + }, []); + const handleMessageDeliveryFailed = useCallback(() => { + toast.error('Message could not be delivered to the bot'); + }, []); + useMessageCacheUpdater( + client, + sandboxId, + conversationId, + clearTypingForMember, + handleActionFailed, + handleMessageDeliveryFailed + ); + + const activeAndFocused = useAppActiveAndFocused(); + const markRead = useMarkRead(client); + const markReadStateRef = useRef(createMarkReadState()); + const markReadRetryStateRef = useRef(createMarkReadRetryState()); + const currentMarkReadMarker = + latestMessageId === null ? null : `${conversationId}:${latestMessageId}`; + const currentMarkReadMarkerRef = useRef(currentMarkReadMarker); + const activeAndFocusedRef = useRef(activeAndFocused); + const markCurrentConversationReadRef = useRef<(() => void) | null>(null); + currentMarkReadMarkerRef.current = currentMarkReadMarker; + activeAndFocusedRef.current = activeAndFocused; + + const markCurrentConversationRead = useCallback(() => { + if (!hasInitialMessages || latestMessageId === null || currentMarkReadMarker === null) { + return; + } + const marker = currentMarkReadMarker; + void attemptMarkCurrentConversationRead({ + marker, + markReadState: markReadStateRef.current, + retryState: markReadRetryStateRef.current, + currentMarker: () => currentMarkReadMarkerRef.current, + isActive: () => activeAndFocusedRef.current, + markRead: async () => { + await markRead(sandboxId, conversationId, latestMessageId); + }, + retry: () => { + markCurrentConversationReadRef.current?.(); + }, + }); + }, [ + conversationId, + currentMarkReadMarker, + hasInitialMessages, + latestMessageId, + markRead, + sandboxId, + ]); + markCurrentConversationReadRef.current = markCurrentConversationRead; + + useEffect(() => { + if (!activeAndFocused || currentMarkReadMarker === null) { + clearMarkReadRetry(markReadRetryStateRef.current); + return; + } + if ( + markReadRetryStateRef.current.marker !== null && + markReadRetryStateRef.current.marker !== currentMarkReadMarker + ) { + clearMarkReadRetry(markReadRetryStateRef.current); + } + }, [activeAndFocused, currentMarkReadMarker]); + + useEffect(() => { + const retryState = markReadRetryStateRef.current; + return () => { + clearMarkReadRetry(retryState); + }; + }, []); + + useEffect(() => { + if (!activeAndFocused) { + return; + } + markCurrentConversationRead(); + }, [activeAndFocused, markCurrentConversationRead]); + + useFocusEffect( + useCallback(() => { + setActiveChatLocation({ sandboxId, conversationId }); + return () => { + setActiveChatLocation(null); + }; + }, [sandboxId, conversationId]) + ); + + if (messageHistoryState === 'loading') { + return ( + + + + + + + ); + } + + if (messageHistoryState === 'error') { + return ( + + + + { + void messagesQuery.refetch(); + }} + /> + + + ); + } + + return ( + + + + + { + setReplyingTo(null); + } + : undefined + } + onCancelEdit={ + editingMessage + ? () => { + setEditingMessage(null); + } + : undefined + } + /> + + { + setReactionPickerMessage(null); + }} + onSelect={emoji => { + const message = reactionPickerMessage; + if (message) { + handleReactionPress(message, emoji); + } + setReactionPickerMessage(null); + }} + /> + + ); +} diff --git a/apps/mobile/src/components/kilo-chat/empty-conversation-list.tsx b/apps/mobile/src/components/kilo-chat/empty-conversation-list.tsx new file mode 100644 index 0000000000..733e3ccae2 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/empty-conversation-list.tsx @@ -0,0 +1,28 @@ +import { MessageSquarePlus } from 'lucide-react-native'; +import { View } from 'react-native'; + +import { EmptyState } from '@/components/empty-state'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; + +type Props = { + onStart: () => void; + isStarting: boolean; +}; + +export function EmptyConversationList({ onStart, isStarting }: Props) { + return ( + + + {isStarting ? 'Starting…' : 'Create conversation'} + + } + /> + + ); +} diff --git a/apps/mobile/src/components/kilo-chat/execute-action-feedback.test.ts b/apps/mobile/src/components/kilo-chat/execute-action-feedback.test.ts new file mode 100644 index 0000000000..2e8a08101d --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/execute-action-feedback.test.ts @@ -0,0 +1,93 @@ +import { QueryClient } from '@tanstack/react-query'; +import { type Message } from '@kilocode/kilo-chat'; +import { + type MessageInfiniteData, + messagesKey, + restoreMessageInCache, +} from '@kilocode/kilo-chat-hooks'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { toast } from 'sonner-native'; + +import { executeActionWithMobileFeedback } from './execute-action-feedback'; + +vi.mock('sonner-native', () => ({ + toast: { + error: vi.fn(), + }, +})); + +function actionMessage(overrides: Partial = {}): Message { + return { + id: 'message-1', + senderId: 'bot:sandbox-1', + content: [ + { + type: 'actions', + groupId: 'approval-1', + actions: [{ label: 'Allow once', style: 'primary', value: 'allow-once' }], + }, + ], + inReplyToMessageId: null, + replyTo: null, + updatedAt: null, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions: [], + ...overrides, + }; +} + +describe('executeActionWithMobileFeedback', () => { + beforeEach(() => { + vi.mocked(toast.error).mockClear(); + }); + + it('shows a toast when the execute-action mutation reports an error', () => { + const mutate = vi.fn((_variables, options?: { onError?: (err: unknown) => void }) => { + options?.onError?.(new Error('offline')); + }); + + executeActionWithMobileFeedback({ + executeAction: { mutate }, + message: actionMessage(), + groupId: 'approval-1', + value: 'allow-once', + }); + + expect(mutate).toHaveBeenCalledWith( + { messageId: 'message-1', groupId: 'approval-1', value: 'allow-once' }, + { onError: expect.any(Function) } + ); + expect(toast.error).toHaveBeenCalledWith('Failed to execute action'); + }); + + it('restores an optimistically resolved action when shared rollback runs', () => { + const queryClient = new QueryClient(); + const queryKey = messagesKey('conversation-1'); + const original = actionMessage(); + const optimistic = actionMessage({ + content: [ + { + type: 'actions', + groupId: 'approval-1', + actions: [{ label: 'Allow once', style: 'primary', value: 'allow-once' }], + resolved: { + value: 'allow-once', + resolvedBy: 'user-1', + resolvedAt: 1, + }, + }, + ], + }); + queryClient.setQueryData(queryKey, { + pages: [{ messages: [optimistic], hasMore: false, nextCursor: null }], + pageParams: [undefined], + }); + + restoreMessageInCache(queryClient, queryKey, original); + + const result = queryClient.getQueryData(queryKey); + expect(result?.pages[0]?.messages[0]).toEqual(original); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/execute-action-feedback.ts b/apps/mobile/src/components/kilo-chat/execute-action-feedback.ts new file mode 100644 index 0000000000..0e9fac66b9 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/execute-action-feedback.ts @@ -0,0 +1,40 @@ +import { type ExecApprovalDecision, formatKiloChatError, type Message } from '@kilocode/kilo-chat'; +import { toast } from 'sonner-native'; + +type ExecuteActionVariables = { + messageId: string; + groupId: string; + value: ExecApprovalDecision; +}; + +type ExecuteActionMutation = { + mutate: ( + variables: ExecuteActionVariables, + options?: { + onError?: (err: unknown) => void; + onSettled?: () => void; + } + ) => void; +}; + +export function executeActionWithMobileFeedback({ + executeAction, + message, + groupId, + value, + onSettled, +}: { + executeAction: ExecuteActionMutation; + message: Message; + groupId: string; + value: ExecApprovalDecision; + onSettled?: () => void; +}) { + const options = { + onError: (err: unknown) => { + toast.error(formatKiloChatError(err, 'Failed to execute action')); + }, + ...(onSettled ? { onSettled } : {}), + }; + executeAction.mutate({ messageId: message.id, groupId, value }, options); +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/mark-read-operation.ts b/apps/mobile/src/components/kilo-chat/hooks/mark-read-operation.ts new file mode 100644 index 0000000000..562efb680b --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/mark-read-operation.ts @@ -0,0 +1,70 @@ +import { type MarkConversationReadResponse } from '@kilocode/kilo-chat'; +import { type BadgeCountRow } from '@kilocode/notifications'; + +type MarkReadConversationInput = { + sandboxId: string; + conversationId: string; + lastSeenMessageId: string; + markConversationRead: (input: { + sandboxId: string; + conversationId: string; + lastSeenMessageId: string; + }) => Promise; +}; + +export async function markReadConversation({ + sandboxId, + conversationId, + lastSeenMessageId, + markConversationRead, +}: MarkReadConversationInput): Promise { + const result = await markConversationRead({ sandboxId, conversationId, lastSeenMessageId }); + return result; +} + +type ApplyBadgeClearResultInput = { + badgeClear: MarkConversationReadResponse['badgeClear']; + startBadgeFreshnessEpoch: number; + currentBadgeFreshnessEpoch: number; + userId: string | null; + updateBadgeRows: ( + queryKey: readonly ['badges', string], + updater: (badges: BadgeCountRow[] | undefined) => BadgeCountRow[] | undefined + ) => void; + setBadgeCount: (badgeCount: number) => Promise; +}; + +export function filterClearedBadgeBucket( + badges: BadgeCountRow[] | undefined, + badgeClear: MarkConversationReadResponse['badgeClear'] +): BadgeCountRow[] | undefined { + if (badgeClear === null) { + return badges; + } + + return badges?.filter(row => row.badgeBucket !== badgeClear.badgeBucket); +} + +export function applyBadgeClearResult({ + badgeClear, + startBadgeFreshnessEpoch, + currentBadgeFreshnessEpoch, + userId, + updateBadgeRows, + setBadgeCount, +}: ApplyBadgeClearResultInput): boolean { + if (badgeClear === null) { + return false; + } + + if (userId !== null) { + updateBadgeRows(['badges', userId], badges => filterClearedBadgeBucket(badges, badgeClear)); + } + + if (currentBadgeFreshnessEpoch !== startBadgeFreshnessEpoch) { + return false; + } + + void setBadgeCount(badgeClear.badgeCount); + return true; +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-app-active-and-focused.ts b/apps/mobile/src/components/kilo-chat/hooks/use-app-active-and-focused.ts new file mode 100644 index 0000000000..f1066b1b03 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-app-active-and-focused.ts @@ -0,0 +1,33 @@ +import { useCallback, useEffect, useState } from 'react'; +import { AppState } from 'react-native'; +import { useFocusEffect } from 'expo-router'; + +/** + * True only when the app is in the foreground AND the current expo-router + * route is focused. Used to gate presence subscriptions so we hold them only + * while the user is genuinely on a surface. + */ +export function useAppActiveAndFocused(): boolean { + const [appActive, setAppActive] = useState(AppState.currentState === 'active'); + const [focused, setFocused] = useState(false); + + useEffect(() => { + const sub = AppState.addEventListener('change', state => { + setAppActive(state === 'active'); + }); + return () => { + sub.remove(); + }; + }, []); + + useFocusEffect( + useCallback(() => { + setFocused(true); + return () => { + setFocused(false); + }; + }, []) + ); + + return appActive && focused; +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-app-presence.ts b/apps/mobile/src/components/kilo-chat/hooks/use-app-presence.ts new file mode 100644 index 0000000000..930cba5e8c --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-app-presence.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from 'react'; +import { AppState } from 'react-native'; + +import { presenceContextForPlatform } from '@kilocode/event-service'; +import { usePresenceSubscription } from '@kilocode/kilo-chat-hooks'; + +export function useAppPresence() { + const [active, setActive] = useState(AppState.currentState === 'active'); + + useEffect(() => { + const sub = AppState.addEventListener('change', state => { + setActive(state === 'active'); + }); + return () => { + sub.remove(); + }; + }, []); + + usePresenceSubscription(presenceContextForPlatform('app'), active); +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-conversation-event-subscription.ts b/apps/mobile/src/components/kilo-chat/hooks/use-conversation-event-subscription.ts new file mode 100644 index 0000000000..65a8e2248d --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-conversation-event-subscription.ts @@ -0,0 +1,36 @@ +import { useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +import { kiloclawConversationContext } from '@kilocode/event-service'; +import { messagesKey } from '@kilocode/kilo-chat-hooks'; + +import { useEventServiceClient } from './use-kilo-chat-client'; + +export function useConversationEventSubscription( + sandboxId: string | undefined, + conversationId: string | undefined +) { + const eventService = useEventServiceClient(); + const queryClient = useQueryClient(); + const context = + sandboxId && conversationId ? kiloclawConversationContext(sandboxId, conversationId) : null; + + useEffect(() => { + if (!context) { + return undefined; + } + eventService.subscribe([context]); + return () => { + eventService.unsubscribe([context]); + }; + }, [eventService, context]); + + useEffect(() => { + if (!conversationId) { + return undefined; + } + return eventService.onReconnect(() => { + void queryClient.invalidateQueries({ queryKey: messagesKey(conversationId) }); + }); + }, [eventService, queryClient, conversationId]); +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-conversation-presence.ts b/apps/mobile/src/components/kilo-chat/hooks/use-conversation-presence.ts new file mode 100644 index 0000000000..bc58d37848 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-conversation-presence.ts @@ -0,0 +1,15 @@ +import { presenceContextForConversation } from '@kilocode/event-service'; +import { usePresenceSubscription } from '@kilocode/kilo-chat-hooks'; + +import { useAppActiveAndFocused } from './use-app-active-and-focused'; + +export function useConversationPresence( + sandboxId: string | undefined, + conversationId: string | undefined +) { + const activeAndFocused = useAppActiveAndFocused(); + usePresenceSubscription( + sandboxId && conversationId ? presenceContextForConversation(sandboxId, conversationId) : null, + Boolean(sandboxId && conversationId) && activeAndFocused + ); +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts b/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts new file mode 100644 index 0000000000..915ddfe511 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts @@ -0,0 +1,35 @@ +import { formatKiloChatError, type KiloChatClient } from '@kilocode/kilo-chat'; +import { + useConversationDetail, + useConversations, + useCreateConversation as useSharedCreateConversation, + useLeaveConversation as useSharedLeaveConversation, + useRenameConversation as useSharedRenameConversation, +} from '@kilocode/kilo-chat-hooks'; +import { toast } from 'sonner-native'; + +export { useConversations, useConversationDetail }; + +export function useCreateConversation(client: KiloChatClient) { + return useSharedCreateConversation(client, { + onError: err => { + toast.error(formatKiloChatError(err, 'Failed to create conversation')); + }, + }); +} + +export function useRenameConversation(client: KiloChatClient) { + return useSharedRenameConversation(client, { + onError: err => { + toast.error(formatKiloChatError(err, 'Failed to rename conversation')); + }, + }); +} + +export function useLeaveConversation(client: KiloChatClient) { + return useSharedLeaveConversation(client, { + onError: err => { + toast.error(formatKiloChatError(err, 'Failed to leave conversation')); + }, + }); +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-current-user-id.ts b/apps/mobile/src/components/kilo-chat/hooks/use-current-user-id.ts new file mode 100644 index 0000000000..8074e54dd4 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-current-user-id.ts @@ -0,0 +1,7 @@ +import { useContext } from 'react'; + +import { KiloChatCurrentUserContext } from '../kilo-chat-provider'; + +export function useCurrentUserId(): string | null { + return useContext(KiloChatCurrentUserContext); +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-event-subscription.ts b/apps/mobile/src/components/kilo-chat/hooks/use-event-subscription.ts new file mode 100644 index 0000000000..e3b783bdad --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-event-subscription.ts @@ -0,0 +1,30 @@ +import { useEffect } from 'react'; + +import { useEventServiceClient } from './use-kilo-chat-client'; + +/** + * Subscribe to a single event-service event for one context. Call this hook + * once per event name when you need multiple events on the same context. + */ +export function useEventSubscription( + context: string | null, + eventName: string, + onEvent: (payload: unknown) => void +) { + const eventService = useEventServiceClient(); + useEffect(() => { + if (!context) { + return undefined; + } + eventService.subscribe([context]); + const off = eventService.on(eventName, (ctx, payload) => { + if (ctx === context) { + onEvent(payload); + } + }); + return () => { + off(); + eventService.unsubscribe([context]); + }; + }, [eventService, context, eventName, onEvent]); +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-instance-event-subscription.ts b/apps/mobile/src/components/kilo-chat/hooks/use-instance-event-subscription.ts new file mode 100644 index 0000000000..238830da8c --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-instance-event-subscription.ts @@ -0,0 +1,42 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +import { kiloclawInstanceContext } from '@kilocode/event-service'; +import { + botStatusKey, + conversationsKey, + registerConversationListCacheHandlers, +} from '@kilocode/kilo-chat-hooks'; + +import { useEventSubscription } from './use-event-subscription'; +import { useCurrentUserId } from './use-current-user-id'; +import { useEventServiceClient, useKiloChatClient } from './use-kilo-chat-client'; + +export function useInstanceEventSubscription(sandboxId: string | undefined) { + const qc = useQueryClient(); + const eventService = useEventServiceClient(); + const kiloChatClient = useKiloChatClient(); + const currentUserId = useCurrentUserId(); + const ctx = sandboxId ? kiloclawInstanceContext(sandboxId) : null; + const queryKey = useMemo(() => conversationsKey(sandboxId ?? null), [sandboxId]); + + const invalidateBotStatus = useCallback(() => { + void qc.invalidateQueries({ queryKey: botStatusKey(sandboxId ?? null) }); + }, [qc, sandboxId]); + + useEventSubscription(ctx, 'bot.status', invalidateBotStatus); + + useEffect(() => { + if (!sandboxId) { + return undefined; + } + return registerConversationListCacheHandlers({ + currentUserId, + eventService, + kiloChatClient, + queryClient: qc, + queryKey, + sandboxId, + }); + }, [currentUserId, eventService, kiloChatClient, qc, queryKey, sandboxId]); +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-instance-presence.ts b/apps/mobile/src/components/kilo-chat/hooks/use-instance-presence.ts new file mode 100644 index 0000000000..204f0d89ff --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-instance-presence.ts @@ -0,0 +1,12 @@ +import { presenceContextForInstance } from '@kilocode/event-service'; +import { usePresenceSubscription } from '@kilocode/kilo-chat-hooks'; + +import { useAppActiveAndFocused } from './use-app-active-and-focused'; + +export function useInstancePresence(sandboxId: string | undefined) { + const activeAndFocused = useAppActiveAndFocused(); + usePresenceSubscription( + sandboxId ? presenceContextForInstance(sandboxId) : null, + Boolean(sandboxId) && activeAndFocused + ); +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-client.ts b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-client.ts new file mode 100644 index 0000000000..cef730a6dc --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-client.ts @@ -0,0 +1 @@ +export { useKiloChatClient, useEventServiceClient } from '@kilocode/kilo-chat-hooks'; diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.test.ts b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.test.ts new file mode 100644 index 0000000000..4ea136b72c --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getItemAsync: vi.fn<() => Promise>(), + getTokenQuery: vi.fn<() => Promise<{ token: string; userId: string; expiresAt: string }>>(), +})); + +vi.mock('react', () => ({ + useCallback: unknown>(fn: T) => fn, +})); + +vi.mock('expo-secure-store', () => ({ + getItemAsync: mocks.getItemAsync, +})); + +vi.mock('@/lib/storage-keys', () => ({ + AUTH_TOKEN_KEY: 'auth-token', +})); + +vi.mock('@/lib/trpc', () => ({ + trpcClient: { + kiloChat: { + getToken: { + query: mocks.getTokenQuery, + }, + }, + }, +})); + +describe('useKiloChatTokenResponseGetter', () => { + beforeEach(async () => { + vi.clearAllMocks(); + const { clearKiloChatTokenCache } = await import('./use-kilo-chat-token'); + clearKiloChatTokenCache(); + }); + + it('notifies subscribers after a later token fetch succeeds', async () => { + const response = { + token: 'kilo-jwt', + userId: 'user-1', + expiresAt: new Date(Date.now() + 3_600_000).toISOString(), + }; + const seenUserIds: string[] = []; + + mocks.getItemAsync.mockResolvedValue('auth-token-1'); + mocks.getTokenQuery.mockRejectedValueOnce(new Error('network down')); + mocks.getTokenQuery.mockResolvedValueOnce(response); + + const { subscribeToKiloChatTokenResponses, useKiloChatTokenResponseGetter } = + await import('./use-kilo-chat-token'); + const unsubscribe = subscribeToKiloChatTokenResponses(tokenResponse => { + seenUserIds.push(tokenResponse.userId); + }); + const getTokenResponse = useKiloChatTokenResponseGetter(); + + await expect(getTokenResponse()).rejects.toThrow('network down'); + expect(seenUserIds).toEqual([]); + + await expect(getTokenResponse()).resolves.toBe(response); + expect(seenUserIds).toEqual(['user-1']); + + unsubscribe(); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts new file mode 100644 index 0000000000..c784652d8e --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts @@ -0,0 +1,89 @@ +import * as SecureStore from 'expo-secure-store'; +import { useCallback } from 'react'; + +import { AUTH_TOKEN_KEY } from '@/lib/storage-keys'; +import { trpcClient } from '@/lib/trpc'; + +type KiloChatTokenResponse = Awaited>; + +type TokenCache = { + authToken: string; + response: KiloChatTokenResponse; + expiresAtMs: number; +}; + +type TokenResponseListener = (response: KiloChatTokenResponse) => void; + +// Module-level cache keyed on the user's auth token, so a sign-out followed by +// a different sign-in within the JWT window doesn't return the previous user's +// token. The in-flight ref is keyed the same way for the same reason. +let cache: TokenCache | null = null; +let inFlight: { authToken: string; promise: Promise } | null = null; +const tokenResponseListeners = new Set(); + +export function clearKiloChatTokenCache(): void { + cache = null; + inFlight = null; +} + +export function subscribeToKiloChatTokenResponses(listener: TokenResponseListener): () => void { + tokenResponseListeners.add(listener); + return () => { + tokenResponseListeners.delete(listener); + }; +} + +/** + * Returns a stable getter function that fetches a kilo-chat JWT, caching it + * until 60 seconds before expiry. Concurrent callers share a single in-flight + * fetch via a module-level dedup ref. + * + * The auth token is read from SecureStore at call time (matching `trpcClient`) + * rather than captured from `useAuth()`, so a getter constructed before auth + * has loaded — or before the user signs in — picks up the correct token on + * its next call instead of permanently capturing `undefined`. + */ +export function useKiloChatTokenGetter(): () => Promise { + const getTokenResponse = useKiloChatTokenResponseGetter(); + return useCallback(async () => { + const response = await getTokenResponse(); + return response.token; + }, [getTokenResponse]); +} + +export function useKiloChatTokenResponseGetter(): () => Promise { + return useCallback(async () => { + const authToken = await SecureStore.getItemAsync(AUTH_TOKEN_KEY); + if (!authToken) { + throw new Error('Cannot fetch kilo-chat token: not authenticated'); + } + + if (cache && cache.authToken === authToken && cache.expiresAtMs - Date.now() > 60_000) { + return cache.response; + } + + if (inFlight && inFlight.authToken === authToken) { + return inFlight.promise; + } + + const slot = { authToken, promise: fetchAndCacheToken(authToken) }; + inFlight = slot; + try { + return await slot.promise; + } finally { + // Only clear the slot if a concurrent caller hasn't replaced it. + if (inFlight === slot) { + inFlight = null; + } + } + }, []); +} + +async function fetchAndCacheToken(authToken: string): Promise { + const response = await trpcClient.kiloChat.getToken.query(); + cache = { authToken, response, expiresAtMs: new Date(response.expiresAt).getTime() }; + for (const listener of tokenResponseListeners) { + listener(response); + } + return response; +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts new file mode 100644 index 0000000000..043054948e --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts @@ -0,0 +1,74 @@ +import { useCallback } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import * as Notifications from 'expo-notifications'; +import { toast } from 'sonner-native'; + +import { type KiloChatClient, type MarkConversationReadResponse } from '@kilocode/kilo-chat'; +import { type BadgeCountRow } from '@kilocode/notifications'; +import { useMarkConversationRead } from '@kilocode/kilo-chat-hooks'; + +import { useCurrentUserId } from './use-current-user-id'; +import { applyBadgeClearResult, markReadConversation } from './mark-read-operation'; +import { advanceBadgeFreshnessEpoch, readBadgeFreshnessEpoch } from '@/lib/badge-freshness'; + +type MarkReadInput = { + sandboxId: string; + conversationId: string; + lastSeenMessageId: string; +}; + +export function useMarkRead(client: KiloChatClient) { + const queryClient = useQueryClient(); + const userId = useCurrentUserId(); + const markConversationRead = useMarkConversationRead(client); + + const mutation = useMutation({ + mutationFn: async ({ + sandboxId, + conversationId, + lastSeenMessageId, + }: MarkReadInput): Promise => { + const result = await markReadConversation({ + sandboxId, + conversationId, + lastSeenMessageId, + markConversationRead: markConversationRead.mutateAsync, + }); + return result; + }, + onError: error => { + toast.error(error.message); + }, + onMutate: () => ({ startBadgeFreshnessEpoch: advanceBadgeFreshnessEpoch() }), + onSuccess: (result, _variables, context) => { + const currentBadgeFreshnessEpoch = readBadgeFreshnessEpoch(); + applyBadgeClearResult({ + badgeClear: result.badgeClear, + startBadgeFreshnessEpoch: context.startBadgeFreshnessEpoch, + currentBadgeFreshnessEpoch, + userId, + updateBadgeRows: (queryKey, updater) => { + queryClient.setQueryData(queryKey, updater); + }, + setBadgeCount: Notifications.setBadgeCountAsync, + }); + }, + onSettled: () => { + if (userId !== null) { + void queryClient.invalidateQueries({ queryKey: ['badges', userId] }); + } + }, + }); + + return useCallback( + async (sandboxId: string, conversationId: string, lastSeenMessageId: string) => { + const result = await mutation.mutateAsync({ + sandboxId, + conversationId, + lastSeenMessageId, + }); + return result; + }, + [mutation] + ); +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-messages.ts b/apps/mobile/src/components/kilo-chat/hooks/use-messages.ts new file mode 100644 index 0000000000..8ae382eb99 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-messages.ts @@ -0,0 +1,21 @@ +import { formatKiloChatError, type KiloChatClient } from '@kilocode/kilo-chat'; +import { + useMessageCacheUpdater, + useMessages, + useSendMessage as useSharedSendMessage, +} from '@kilocode/kilo-chat-hooks'; +import { toast } from 'sonner-native'; + +export { useMessages, useMessageCacheUpdater }; + +export function useSendMessage( + client: KiloChatClient, + conversationId: string | null, + currentUserId: string | null +) { + return useSharedSendMessage(client, conversationId, currentUserId, { + onError: err => { + toast.error(formatKiloChatError(err, 'Failed to send message')); + }, + }); +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-now-ticker.ts b/apps/mobile/src/components/kilo-chat/hooks/use-now-ticker.ts new file mode 100644 index 0000000000..f112854512 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-now-ticker.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from 'react'; + +export function useNowTicker(intervalMs: number): number { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const timer = setInterval(() => { + setNow(Date.now()); + }, intervalMs); + return () => { + clearInterval(timer); + }; + }, [intervalMs]); + + return now; +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-typing.test.ts b/apps/mobile/src/components/kilo-chat/hooks/use-typing.test.ts new file mode 100644 index 0000000000..c553940bb3 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-typing.test.ts @@ -0,0 +1,87 @@ +import { kiloclawConversationContext } from '@kilocode/event-service'; +import { describe, expect, it } from 'vitest'; + +import { + applyTypingStarted, + applyTypingStopped, + pruneStaleTypingMembers, + sendTypingPingIfDue, +} from './use-typing'; + +describe('mobile typing state helpers', () => { + const expectedContext = kiloclawConversationContext('sandbox-1', 'conversation-1'); + + it('tracks typing events for the active conversation and removes typing.stop members', () => { + const typingMembers = applyTypingStarted(new Map(), { + ctx: expectedContext, + event: { memberId: 'user-2' }, + currentUserId: 'user-1', + expectedContext, + now: 10, + }); + + expect([...typingMembers.entries()]).toEqual([['user-2', 10]]); + expect( + applyTypingStarted(typingMembers, { + ctx: kiloclawConversationContext('sandbox-1', 'other-conversation'), + event: { memberId: 'user-3' }, + currentUserId: 'user-1', + expectedContext, + now: 20, + }) + ).toBe(typingMembers); + expect( + applyTypingStarted(typingMembers, { + ctx: expectedContext, + event: { memberId: 'user-1' }, + currentUserId: 'user-1', + expectedContext, + now: 30, + }) + ).toBe(typingMembers); + + const stopped = applyTypingStopped(typingMembers, { + ctx: expectedContext, + memberId: 'user-2', + expectedContext, + }); + expect(stopped.size).toBe(0); + }); + + it('expires stale typing members', () => { + const typingMembers = new Map([ + ['recent-user', 4000], + ['stale-user', 1000], + ]); + + expect([...pruneStaleTypingMembers(typingMembers, 6000).keys()]).toEqual(['recent-user']); + }); + + it('sends typing pings at most once per cooldown window and swallows failures', async () => { + const sent: string[] = []; + const client = { + sendTyping: async (conversationId: string) => { + sent.push(conversationId); + await Promise.resolve(); + throw new Error('offline'); + }, + }; + + let lastSentAt = sendTypingPingIfDue({ + client, + conversationId: 'conversation-1', + lastSentAt: 0, + now: 4000, + }); + lastSentAt = sendTypingPingIfDue({ + client, + conversationId: 'conversation-1', + lastSentAt, + now: 5000, + }); + + await Promise.resolve(); + expect(sent).toEqual(['conversation-1']); + expect(lastSentAt).toBe(4000); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-typing.ts b/apps/mobile/src/components/kilo-chat/hooks/use-typing.ts new file mode 100644 index 0000000000..8ed867d97a --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-typing.ts @@ -0,0 +1,229 @@ +import { kiloclawConversationContext } from '@kilocode/event-service'; +import { type KiloChatClient, type TypingEvent } from '@kilocode/kilo-chat'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +const TYPING_COOLDOWN_MS = 3000; +const TYPING_DISPLAY_TIMEOUT_MS = 5000; + +type TypingSenderClient = { + sendTyping(conversationId: string): Promise; +}; + +export function applyTypingStarted( + typingMembers: Map, + { + ctx, + event, + currentUserId, + expectedContext, + now, + }: { + ctx: string; + event: TypingEvent; + currentUserId: string | null; + expectedContext: string | null; + now: number; + } +) { + if (!expectedContext || ctx !== expectedContext) { + return typingMembers; + } + if (event.memberId === currentUserId) { + return typingMembers; + } + + return new Map([...typingMembers, [event.memberId, now]]); +} + +export function applyTypingStopped( + typingMembers: Map, + { + ctx, + memberId, + expectedContext, + }: { + ctx: string; + memberId: string; + expectedContext: string | null; + } +) { + if (!expectedContext || ctx !== expectedContext) { + return typingMembers; + } + if (!typingMembers.has(memberId)) { + return typingMembers; + } + + const next = new Map(typingMembers); + next.delete(memberId); + return next; +} + +export function pruneStaleTypingMembers( + typingMembers: Map, + now: number, + timeoutMs = TYPING_DISPLAY_TIMEOUT_MS +) { + const next = new Map(); + for (const [memberId, lastSeenAt] of typingMembers) { + if (now - lastSeenAt < timeoutMs) { + next.set(memberId, lastSeenAt); + } + } + return next; +} + +export function sendTypingPingIfDue({ + client, + conversationId, + lastSentAt, + now, + cooldownMs = TYPING_COOLDOWN_MS, +}: { + client: TypingSenderClient; + conversationId: string | null; + lastSentAt: number; + now: number; + cooldownMs?: number; +}) { + if (!conversationId) { + return lastSentAt; + } + if (now - lastSentAt < cooldownMs) { + return lastSentAt; + } + + void (async () => { + try { + await client.sendTyping(conversationId); + } catch { + // Typing pings are best-effort and should never block composing. + } + })(); + return now; +} + +export function useTypingSender(client: KiloChatClient, conversationId: string | null) { + const lastSentAtRef = useRef(0); + + return useCallback(() => { + lastSentAtRef.current = sendTypingPingIfDue({ + client, + conversationId, + lastSentAt: lastSentAtRef.current, + now: Date.now(), + }); + }, [client, conversationId]); +} + +export function useMobileTypingState({ + client, + currentUserId, + sandboxId, + conversationId, +}: { + client: KiloChatClient; + currentUserId: string | null; + sandboxId: string | null; + conversationId: string | null; +}) { + const expectedContext = useMemo( + () => + sandboxId && conversationId ? kiloclawConversationContext(sandboxId, conversationId) : null, + [conversationId, sandboxId] + ); + const [typingMembers, setTypingMembers] = useState>(new Map()); + const timersRef = useRef>>(new Map()); + + const clearTimer = useCallback((memberId: string) => { + const timer = timersRef.current.get(memberId); + if (!timer) { + return; + } + clearTimeout(timer); + timersRef.current.delete(memberId); + }, []); + + const clearTypingForMember = useCallback( + (ctx: string, memberId: string) => { + if (!expectedContext || ctx !== expectedContext) { + return; + } + clearTimer(memberId); + setTypingMembers(prev => applyTypingStopped(prev, { ctx, memberId, expectedContext })); + }, + [clearTimer, expectedContext] + ); + + const handleTyping = useCallback( + (ctx: string, event: TypingEvent) => { + if (!expectedContext || ctx !== expectedContext) { + return; + } + if (event.memberId === currentUserId) { + return; + } + + const now = Date.now(); + setTypingMembers(prev => + applyTypingStarted(pruneStaleTypingMembers(prev, now), { + ctx, + event, + currentUserId, + expectedContext, + now, + }) + ); + + clearTimer(event.memberId); + const timer = setTimeout(() => { + setTypingMembers(prev => { + const next = new Map(prev); + next.delete(event.memberId); + return next; + }); + timersRef.current.delete(event.memberId); + }, TYPING_DISPLAY_TIMEOUT_MS); + timersRef.current.set(event.memberId, timer); + }, + [clearTimer, currentUserId, expectedContext] + ); + + useEffect(() => { + setTypingMembers(new Map()); + for (const timer of timersRef.current.values()) { + clearTimeout(timer); + } + timersRef.current.clear(); + }, [expectedContext]); + + useEffect(() => { + if (!expectedContext) { + return undefined; + } + + const offs = [ + client.onTyping(handleTyping), + client.onTypingStop((ctx, event) => { + clearTypingForMember(ctx, event.memberId); + }), + ]; + return () => { + for (const off of offs) { + off(); + } + }; + }, [client, clearTypingForMember, expectedContext, handleTyping]); + + useEffect(() => { + const timers = timersRef.current; + return () => { + for (const timer of timers.values()) { + clearTimeout(timer); + } + timers.clear(); + }; + }, []); + + return { typingMembers, clearTypingForMember }; +} diff --git a/apps/mobile/src/components/kilo-chat/kilo-chat-presence-mount.tsx b/apps/mobile/src/components/kilo-chat/kilo-chat-presence-mount.tsx new file mode 100644 index 0000000000..6a5b4dbcae --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/kilo-chat-presence-mount.tsx @@ -0,0 +1,10 @@ +import { type ReactNode } from 'react'; + +import { useAppPresence } from './hooks/use-app-presence'; +import { useUnreadCountsInvalidation } from '@/lib/hooks/use-unread-counts-invalidation'; + +export function KiloChatPresenceMount({ children }: { children: ReactNode }) { + useAppPresence(); + useUnreadCountsInvalidation(); + return <>{children}; +} diff --git a/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx b/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx new file mode 100644 index 0000000000..3335ab9d6d --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx @@ -0,0 +1,92 @@ +import { createContext, useEffect, useState } from 'react'; + +import { EventServiceClient } from '@kilocode/event-service'; +import { KiloChatClient } from '@kilocode/kilo-chat'; +import { KiloChatHooksProvider } from '@kilocode/kilo-chat-hooks'; + +import { EVENT_SERVICE_URL, KILO_CHAT_URL } from '@/lib/config'; + +import { + clearKiloChatTokenCache, + subscribeToKiloChatTokenResponses, + useKiloChatTokenGetter, + useKiloChatTokenResponseGetter, +} from './hooks/use-kilo-chat-token'; + +type KiloChatProviderProps = { + children: React.ReactNode; +}; + +export const KiloChatCurrentUserContext = createContext(null); + +export function KiloChatProvider({ children }: KiloChatProviderProps) { + const getToken = useKiloChatTokenGetter(); + const getTokenResponse = useKiloChatTokenResponseGetter(); + const [currentUserId, setCurrentUserId] = useState(null); + + const [value] = useState(() => { + const eventService = new EventServiceClient({ + url: EVENT_SERVICE_URL, + getToken, + onUnauthorized: () => { + clearKiloChatTokenCache(); + return 'retry'; + }, + }); + const kiloChatClient = new KiloChatClient({ + eventService, + baseUrl: KILO_CHAT_URL, + getToken, + onUnauthorized: () => { + clearKiloChatTokenCache(); + return 'retry'; + }, + }); + return { eventService, kiloChatClient }; + }); + + useEffect(() => { + void value.eventService.connect(); + return () => { + value.eventService.disconnect(); + }; + }, [value]); + + useEffect(() => { + let cancelled = false; + const unsubscribe = subscribeToKiloChatTokenResponses(response => { + if (!cancelled) { + setCurrentUserId(response.userId); + } + }); + + async function resolveCurrentUserId() { + try { + const response = await getTokenResponse(); + if (!cancelled) { + setCurrentUserId(response.userId); + } + } catch { + // Keep the provider in its loading state. A later successful token fetch + // from any Kilo Chat caller will notify the subscription above. + } + } + + void resolveCurrentUserId(); + + return () => { + cancelled = true; + unsubscribe(); + }; + }, [getTokenResponse]); + + return ( + + + {children} + + + ); +} diff --git a/apps/mobile/src/components/kilo-chat/live-message-cache.test.ts b/apps/mobile/src/components/kilo-chat/live-message-cache.test.ts new file mode 100644 index 0000000000..796f9c5b14 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/live-message-cache.test.ts @@ -0,0 +1,299 @@ +import { describe, expect, it } from 'vitest'; +import { QueryClient } from '@tanstack/react-query'; +import { type Message, type MessageCreatedEvent } from '@kilocode/kilo-chat'; + +import { + applyMessageCreatedEventToPages, + applyReactionAdded, + latestMarkReadMessageId, + type MessageInfiniteData, + messagesKey, + restoreMessageInCache, + updateMessageInPages, +} from '@kilocode/kilo-chat-hooks'; + +function messageData( + pages: Message[][], + pageParams: (string | undefined)[] = [undefined] +): MessageInfiniteData { + return { + pages: pages.map((messages, index) => ({ + messages, + hasMore: index < pages.length - 1, + nextCursor: index < pages.length - 1 ? (pageParams[index + 1] ?? null) : null, + })), + pageParams, + }; +} + +function message(id: string): Message { + return { + id, + senderId: 'user:1', + content: [{ type: 'text', text: id }], + inReplyToMessageId: null, + replyTo: null, + updatedAt: null, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions: [], + }; +} + +function actionMessage( + resolved?: NonNullable['resolved']> +): Message { + return { + ...message('action-message'), + senderId: 'bot:sandbox-1', + content: [ + { + type: 'actions', + groupId: 'approval-1', + actions: [{ label: 'Allow once', style: 'primary', value: 'allow-once' }], + resolved, + }, + ], + }; +} + +describe('applyMessageCreatedEventToPages', () => { + it('adds bot-created messages to the open conversation cache', () => { + const data = messageData([[message('01HX0000000000000000000000')]]); + const event = { + messageId: '01HX0000000000000000000001', + senderId: 'bot:sandbox-1', + content: [{ type: 'text', text: 'hello from bot' }], + inReplyToMessageId: null, + replyTo: null, + clientId: null, + } satisfies MessageCreatedEvent; + + const result = applyMessageCreatedEventToPages(data, event); + + expect(result.pages[0]?.messages.map(m => m.id)).toEqual([ + '01HX0000000000000000000001', + '01HX0000000000000000000000', + ]); + }); + + it('keeps the newest page ordered when an older remote message arrives after a newer one', () => { + const newerRemote = message('01HX0000000000000000000002'); + const data = messageData([[newerRemote]]); + const event = { + messageId: '01HX0000000000000000000001', + senderId: 'bot:sandbox-1', + content: [{ type: 'text', text: 'older delayed message' }], + inReplyToMessageId: null, + replyTo: null, + clientId: null, + } satisfies MessageCreatedEvent; + + const result = applyMessageCreatedEventToPages(data, event); + + expect(result.pages[0]?.messages.map(m => m.id)).toEqual([ + '01HX0000000000000000000002', + '01HX0000000000000000000001', + ]); + }); + + it('keeps pending messages in place while ordering delayed remote messages', () => { + const newerRemote = message('01HX0000000000000000000002'); + const pendingLocal = message('pending-client-1'); + const data = messageData([[newerRemote, pendingLocal]]); + const event = { + messageId: '01HX0000000000000000000001', + senderId: 'bot:sandbox-1', + content: [{ type: 'text', text: 'older delayed message' }], + inReplyToMessageId: null, + replyTo: null, + clientId: null, + } satisfies MessageCreatedEvent; + + const result = applyMessageCreatedEventToPages(data, event); + + expect(result.pages[0]?.messages.map(m => m.id)).toEqual([ + '01HX0000000000000000000002', + '01HX0000000000000000000001', + 'pending-client-1', + ]); + }); + + it('preserves reply snapshots from created events when the parent is not loaded', () => { + const data = messageData([[message('existing')]]); + const replyTo = { + messageId: 'parent-outside-loaded-pages', + senderId: 'user:parent', + deleted: false, + previewText: 'Parent context from an older page', + }; + const event = { + messageId: 'reply-message', + senderId: 'bot:sandbox-1', + content: [{ type: 'text', text: 'reply body' }], + inReplyToMessageId: replyTo.messageId, + replyTo, + clientId: null, + } satisfies MessageCreatedEvent; + + const result = applyMessageCreatedEventToPages(data, event); + + expect(result.pages[0]?.messages[0]?.replyTo).toEqual(replyTo); + }); + + it('repositions resolved optimistic messages by newest server id', () => { + const remoteOlder = message('01HX0000000000000000000000'); + const pendingLocal = message('pending-client-1'); + const data = messageData([[remoteOlder, pendingLocal]]); + const event = { + messageId: '01HX0000000000000000000001', + senderId: 'user:1', + content: [{ type: 'text', text: 'local newer' }], + inReplyToMessageId: null, + replyTo: null, + clientId: 'client-1', + } satisfies MessageCreatedEvent; + + const result = applyMessageCreatedEventToPages(data, event); + + expect(result.pages[0]?.messages.map(m => m.id)).toEqual([ + '01HX0000000000000000000001', + '01HX0000000000000000000000', + ]); + }); +}); + +describe('updateMessageInPages', () => { + it('returns the same cache object when the target message is absent', () => { + const data = messageData([[message('m1')], [message('m2')]], [undefined, 'm1']); + + const result = updateMessageInPages(data, 'missing', msg => ({ ...msg, deleted: true })); + + expect(result).toBe(data); + }); + + it('copies only the pages array and containing page when updating a message', () => { + const firstPage = { messages: [message('m1')], hasMore: true, nextCursor: 'm1' }; + const secondPage = { messages: [message('m2')], hasMore: false, nextCursor: null }; + const data: MessageInfiniteData = { + pages: [firstPage, secondPage], + pageParams: [undefined, 'm1'], + }; + + const result = updateMessageInPages(data, 'm2', msg => ({ ...msg, deleted: true })); + + expect(result).not.toBe(data); + expect(result.pages).not.toBe(data.pages); + expect(result.pages[0]).toBe(firstPage); + expect(result.pages[1]).not.toBe(secondPage); + expect(result.pages[1]?.messages[0]?.deleted).toBe(true); + }); +}); + +describe('shared optimistic rollback helpers', () => { + it('restores snapshotted message content for edit and delete rollbacks', () => { + const queryClient = new QueryClient(); + const queryKey = messagesKey('conv-rollback'); + const original = message('m1'); + const optimistic = { + ...original, + content: [{ type: 'text' as const, text: 'edited' }], + deleted: true, + }; + queryClient.setQueryData(queryKey, messageData([[optimistic]])); + + const restored = restoreMessageInCache( + queryClient, + queryKey, + original, + current => JSON.stringify(current) === JSON.stringify(optimistic) + ); + + const result = queryClient.getQueryData(queryKey); + expect(restored).toBe(true); + expect(result?.pages[0]?.messages[0]).toEqual(original); + }); + + it('leaves server-resolved actions intact when failed rollback sees newer content', () => { + const queryClient = new QueryClient(); + const queryKey = messagesKey('conv-action-race'); + const original = actionMessage(); + const optimisticResolution = { + value: 'allow-once', + resolvedBy: 'user-losing-request', + resolvedAt: 1, + }; + const serverResolved = actionMessage({ + value: 'deny', + resolvedBy: 'user-winning-request', + resolvedAt: 2, + }); + queryClient.setQueryData(queryKey, messageData([[serverResolved]])); + + const restored = restoreMessageInCache(queryClient, queryKey, original, current => + current.content.some(block => { + if (block.type !== 'actions') { + return false; + } + if (block.groupId !== 'approval-1') { + return false; + } + return ( + block.resolved?.value === optimisticResolution.value && + block.resolved.resolvedBy === optimisticResolution.resolvedBy && + block.resolved.resolvedAt === optimisticResolution.resolvedAt + ); + }) + ); + + const result = queryClient.getQueryData(queryKey); + expect(restored).toBe(false); + expect(result?.pages[0]?.messages[0]).toEqual(serverResolved); + }); + + it('leaves server-updated text intact when failed edit rollback sees newer content', () => { + const queryClient = new QueryClient(); + const queryKey = messagesKey('conv-edit-race'); + const original = message('m1'); + const optimistic: Message = { + ...original, + content: [{ type: 'text', text: 'optimistic edit' }], + clientUpdatedAt: 1, + }; + const serverUpdated: Message = { + ...original, + content: [{ type: 'text', text: 'server edit' }], + clientUpdatedAt: 2, + }; + queryClient.setQueryData(queryKey, messageData([[serverUpdated]])); + + const restored = restoreMessageInCache( + queryClient, + queryKey, + original, + current => + JSON.stringify(current.content) === JSON.stringify(optimistic.content) && + current.clientUpdatedAt === optimistic.clientUpdatedAt + ); + + const result = queryClient.getQueryData(queryKey); + expect(restored).toBe(false); + expect(result?.pages[0]?.messages[0]).toEqual(serverUpdated); + }); + + it('creates the first reaction summary when adding a new emoji', () => { + expect(applyReactionAdded([], '👍', 'user-1')).toEqual([ + { emoji: '👍', count: 1, memberIds: ['user-1'] }, + ]); + }); +}); + +describe('latestMarkReadMessageId', () => { + it('skips pending optimistic messages when selecting the newest read boundary', () => { + expect(latestMarkReadMessageId([message('real-message'), message('pending-client-1')])).toBe( + 'real-message' + ); + expect(latestMarkReadMessageId([message('pending-client-1')])).toBeNull(); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/live-message-update-cache.test.ts b/apps/mobile/src/components/kilo-chat/live-message-update-cache.test.ts new file mode 100644 index 0000000000..180b238e39 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/live-message-update-cache.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { type Message, type MessageUpdatedEvent } from '@kilocode/kilo-chat'; + +import { + applyMessageUpdatedEventToPages, + type MessageInfiniteData, +} from '@kilocode/kilo-chat-hooks'; + +function message(id: string): Message { + return { + id, + senderId: 'user:1', + content: [{ type: 'text', text: id }], + inReplyToMessageId: null, + replyTo: null, + updatedAt: null, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions: [], + }; +} + +function actionMessage( + resolved?: NonNullable['resolved']> +): Message { + return { + ...message('action-message'), + senderId: 'bot:sandbox-1', + content: [ + { + type: 'actions', + groupId: 'approval-1', + actions: [{ label: 'Allow once', style: 'primary', value: 'allow-once' }], + resolved, + }, + ], + }; +} + +describe('applyMessageUpdatedEventToPages', () => { + it('ignores delayed edit events older than the cached edit timestamp', () => { + const newerMessage: Message = { + ...message('01HX0000000000000000000001'), + content: [{ type: 'text', text: 'newer content' }], + clientUpdatedAt: 2, + }; + const data: MessageInfiniteData = { + pages: [{ messages: [newerMessage], hasMore: false, nextCursor: null }], + pageParams: [undefined], + }; + const event = { + messageId: newerMessage.id, + content: [{ type: 'text', text: 'older content' }], + clientUpdatedAt: 1, + } satisfies MessageUpdatedEvent; + + const result = applyMessageUpdatedEventToPages(data, event); + + expect(result).toBe(data); + expect(result.pages[0]?.messages[0]).toEqual(newerMessage); + }); + + it('applies null-timestamp action resolution updates to edited cached messages', () => { + const cachedMessage: Message = { + ...actionMessage(), + clientUpdatedAt: 2, + }; + const resolvedContent = actionMessage({ + value: 'allow-once', + resolvedBy: 'user-1', + resolvedAt: 3, + }).content; + const data: MessageInfiniteData = { + pages: [{ messages: [cachedMessage], hasMore: false, nextCursor: null }], + pageParams: [undefined], + }; + const event = { + messageId: cachedMessage.id, + content: resolvedContent, + clientUpdatedAt: null, + } satisfies MessageUpdatedEvent; + + const result = applyMessageUpdatedEventToPages(data, event); + + expect(result.pages[0]?.messages[0]?.content).toEqual(resolvedContent); + expect(result.pages[0]?.messages[0]?.clientUpdatedAt).toBe(2); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/mark-read-state.test.ts b/apps/mobile/src/components/kilo-chat/mark-read-state.test.ts new file mode 100644 index 0000000000..9b2805a8da --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/mark-read-state.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { type BadgeCountRow } from '@kilocode/notifications'; +import { + createMarkReadState, + finishMarkReadAttempt, + shouldStartMarkReadAttempt, + startMarkReadAttempt, + succeedMarkReadAttempt, +} from '@kilocode/kilo-chat-hooks'; +import { + applyBadgeClearResult, + filterClearedBadgeBucket, + markReadConversation, +} from './hooks/mark-read-operation'; +import { reconcileHydratedBadgeCount, totalBadgeCount } from '@/lib/badge-hydration'; + +type UpdateBadgeRows = ( + queryKey: readonly ['badges', string], + updater: (badges: BadgeCountRow[] | undefined) => BadgeCountRow[] | undefined +) => void; + +function createUpdateBadgeRowsMock() { + return vi.fn((queryKey, updater) => { + expect(queryKey[0]).toBe('badges'); + void updater(undefined); + }); +} + +describe('mark-read attempt state', () => { + it('retries the same visible message after a failed attempt settles', () => { + const state = createMarkReadState(); + const marker = 'conversation-1:message-1'; + + expect(shouldStartMarkReadAttempt(state, marker)).toBe(true); + + startMarkReadAttempt(state, marker); + expect(shouldStartMarkReadAttempt(state, marker)).toBe(false); + + finishMarkReadAttempt(state, marker); + expect(shouldStartMarkReadAttempt(state, marker)).toBe(true); + }); + + it('does not retry the same visible message after a successful attempt settles', () => { + const state = createMarkReadState(); + const marker = 'conversation-1:message-1'; + + startMarkReadAttempt(state, marker); + succeedMarkReadAttempt(state, marker); + finishMarkReadAttempt(state, marker); + + expect(shouldStartMarkReadAttempt(state, marker)).toBe(false); + }); +}); + +describe('markReadConversation', () => { + it('uses the Kilo Chat response without calling the raw Notifications badge endpoint', async () => { + const state = createMarkReadState(); + const marker = 'conversation-1:message-1'; + let membershipReadCount = 0; + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + try { + startMarkReadAttempt(state, marker); + const result = await markReadConversation({ + sandboxId: 'sandbox-1', + conversationId: 'conversation-1', + lastSeenMessageId: 'message-1', + markConversationRead: async () => { + await Promise.resolve(); + membershipReadCount += 1; + return { ok: true, applied: true, lastReadAt: 1, badgeClear: null }; + }, + }); + succeedMarkReadAttempt(state, marker); + finishMarkReadAttempt(state, marker); + + expect(result).toEqual({ ok: true, applied: true, lastReadAt: 1, badgeClear: null }); + expect(membershipReadCount).toBe(1); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(shouldStartMarkReadAttempt(state, marker)).toBe(false); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('leaves badge rows untouched when the Kilo Chat response did not clear the bucket', () => { + const badgeRows = [ + { badgeBucket: 'bucket-1', badgeCount: 2 }, + { badgeBucket: 'bucket-2', badgeCount: 1 }, + ]; + + expect(filterClearedBadgeBucket(badgeRows, null)).toBe(badgeRows); + }); + + it('removes only the returned cleared badge row', () => { + expect( + filterClearedBadgeBucket( + [ + { badgeBucket: 'bucket-1', badgeCount: 2 }, + { badgeBucket: 'bucket-2', badgeCount: 1 }, + ], + { badgeBucket: 'bucket-2', badgeCount: 1 } + ) + ).toEqual([{ badgeBucket: 'bucket-1', badgeCount: 2 }]); + }); + + it('does not update badge cache or OS badge count when badgeClear is null', () => { + const updateBadgeRows = createUpdateBadgeRowsMock(); + const setBadgeCount = vi.fn<(badgeCount: number) => Promise>(async () => { + const result = await Promise.resolve(true); + return result; + }); + + const applied = applyBadgeClearResult({ + badgeClear: null, + startBadgeFreshnessEpoch: 0, + currentBadgeFreshnessEpoch: 0, + userId: 'user-1', + updateBadgeRows, + setBadgeCount, + }); + + expect(applied).toBe(false); + expect(updateBadgeRows).not.toHaveBeenCalled(); + expect(setBadgeCount).not.toHaveBeenCalled(); + }); + + it('updates badge cache and OS badge count when badgeClear includes a count with unchanged freshness', () => { + const updateBadgeRows = createUpdateBadgeRowsMock(); + const setBadgeCount = vi.fn<(badgeCount: number) => Promise>(async () => { + const result = await Promise.resolve(true); + return result; + }); + + const applied = applyBadgeClearResult({ + badgeClear: { badgeBucket: 'server-bucket', badgeCount: 3 }, + startBadgeFreshnessEpoch: 4, + currentBadgeFreshnessEpoch: 4, + userId: 'user-1', + updateBadgeRows, + setBadgeCount, + }); + + expect(applied).toBe(true); + expect(updateBadgeRows).toHaveBeenCalledOnce(); + expect(updateBadgeRows).toHaveBeenCalledWith(['badges', 'user-1'], expect.any(Function)); + expect(setBadgeCount).toHaveBeenCalledWith(3); + }); + + it('keeps badge cache updates but skips stale OS badge counts when freshness advanced', () => { + const updateBadgeRows = createUpdateBadgeRowsMock(); + const setBadgeCount = vi.fn<(badgeCount: number) => Promise>(async () => { + const result = await Promise.resolve(true); + return result; + }); + + const applied = applyBadgeClearResult({ + badgeClear: { badgeBucket: 'server-bucket', badgeCount: 0 }, + startBadgeFreshnessEpoch: 8, + currentBadgeFreshnessEpoch: 9, + userId: 'user-1', + updateBadgeRows, + setBadgeCount, + }); + + expect(applied).toBe(false); + expect(updateBadgeRows).toHaveBeenCalledOnce(); + expect(updateBadgeRows).toHaveBeenCalledWith(['badges', 'user-1'], expect.any(Function)); + expect(setBadgeCount).not.toHaveBeenCalled(); + }); +}); + +describe('badge hydration reconciliation', () => { + it('totals all hydrated badge buckets for the native OS badge', () => { + expect( + totalBadgeCount([ + { badgeBucket: 'kiloclaw:sandbox-1', badgeCount: 2 }, + { badgeBucket: 'kiloclaw:sandbox-1:conversation-1', badgeCount: 3 }, + ]) + ).toBe(5); + }); + + it('updates the native OS badge when hydration is still fresh', () => { + const setBadgeCount = vi.fn<(badgeCount: number) => Promise>(async () => { + const result = await Promise.resolve(true); + return result; + }); + + const applied = reconcileHydratedBadgeCount({ + badgeRows: [ + { badgeBucket: 'kiloclaw:sandbox-1', badgeCount: 2 }, + { badgeBucket: 'kiloclaw:sandbox-1:conversation-1', badgeCount: 3 }, + ], + startBadgeFreshnessEpoch: 10, + currentBadgeFreshnessEpoch: 10, + setBadgeCount, + }); + + expect(applied).toBe(true); + expect(setBadgeCount).toHaveBeenCalledWith(5); + }); + + it('does not overwrite a newer native OS badge update from stale hydration', () => { + const setBadgeCount = vi.fn<(badgeCount: number) => Promise>(async () => { + const result = await Promise.resolve(true); + return result; + }); + + const applied = reconcileHydratedBadgeCount({ + badgeRows: [{ badgeBucket: 'kiloclaw:sandbox-1', badgeCount: 4 }], + startBadgeFreshnessEpoch: 10, + currentBadgeFreshnessEpoch: 11, + setBadgeCount, + }); + + expect(applied).toBe(false); + expect(setBadgeCount).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/message-actions.test.ts b/apps/mobile/src/components/kilo-chat/message-actions.test.ts new file mode 100644 index 0000000000..dfb811f2fd --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-actions.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; + +import { buildMessageActionSheetOptions, getSelectedMessageAction } from './message-actions'; + +describe('buildMessageActionSheetOptions', () => { + it('offers first-reaction choices for messages with no reactions', () => { + const options = buildMessageActionSheetOptions({ + canReact: true, + canReply: true, + canCopy: true, + canEdit: false, + canDelete: false, + }); + + expect(options.options).toContain('👍'); + expect(options.options).toContain('❤️'); + expect(options.options).not.toContain('👍 React'); + expect(options.cancelButtonIndex).toBe(options.options.length - 1); + }); + + it('offers edit and delete actions only for own messages', () => { + const ownOptions = buildMessageActionSheetOptions({ + canReact: true, + canReply: true, + canCopy: true, + canEdit: true, + canDelete: true, + }); + const otherOptions = buildMessageActionSheetOptions({ + canReact: true, + canReply: true, + canCopy: true, + canEdit: false, + canDelete: false, + }); + + expect(ownOptions.options).toContain('Edit'); + expect(ownOptions.options).toContain('Delete'); + expect(ownOptions.destructiveButtonIndex).toBe(ownOptions.options.indexOf('Delete')); + expect(otherOptions.options).not.toContain('Edit'); + expect(otherOptions.options).not.toContain('Delete'); + expect(otherOptions.destructiveButtonIndex).toBeUndefined(); + }); + + it('offers reply only when allowed for the message', () => { + const replyableOptions = buildMessageActionSheetOptions({ + canReact: true, + canReply: true, + canCopy: true, + canEdit: false, + canDelete: false, + }); + const failedDeliveryOptions = buildMessageActionSheetOptions({ + canReact: true, + canReply: false, + canCopy: true, + canEdit: false, + canDelete: false, + }); + + expect(replyableOptions.options).toContain('Reply'); + expect(failedDeliveryOptions.options).not.toContain('Reply'); + }); + + it('keeps reply as the first action when reactions are disabled', () => { + const actionSheet = buildMessageActionSheetOptions({ + canReact: false, + canReply: true, + canCopy: false, + canEdit: false, + canDelete: false, + }); + + expect(actionSheet.options).toEqual(['Reply', 'Cancel']); + expect(actionSheet.actions[0]).toEqual({ kind: 'reply', label: 'Reply' }); + }); + + it('resolves selected action by action identity instead of raw option index', () => { + const actionSheet = buildMessageActionSheetOptions({ + canReact: false, + canReply: true, + canCopy: false, + canEdit: false, + canDelete: false, + }); + + const selectedAction = getSelectedMessageAction(actionSheet, 0); + + expect(selectedAction).toEqual({ kind: 'reply', label: 'Reply' }); + expect(selectedAction?.kind).not.toBe('reaction'); + }); + + it('offers no API-backed actions for pending messages', () => { + const actionSheet = buildMessageActionSheetOptions({ + canReact: true, + canReply: true, + canCopy: false, + canEdit: true, + canDelete: true, + isPendingMessage: true, + }); + + expect(actionSheet.options).toEqual(['Cancel']); + expect(actionSheet.actions.every(action => action.kind === 'cancel')).toBe(true); + expect(actionSheet.destructiveButtonIndex).toBeUndefined(); + expect(getSelectedMessageAction(actionSheet, 0)).toBeNull(); + }); + + it('offers delete and cancel only for own delivery-failed messages', () => { + const actionSheet = buildMessageActionSheetOptions({ + canReact: false, + canReply: false, + canCopy: true, + canEdit: false, + canDelete: true, + }); + + expect(actionSheet.options).toEqual(['Copy', 'Delete', 'Cancel']); + expect(actionSheet.destructiveButtonIndex).toBe(1); + }); + + it('offers cancel only for non-own delivery-failed messages', () => { + const actionSheet = buildMessageActionSheetOptions({ + canReact: false, + canReply: false, + canCopy: false, + canEdit: false, + canDelete: false, + }); + + expect(actionSheet.options).toEqual(['Cancel']); + expect(actionSheet.destructiveButtonIndex).toBeUndefined(); + }); + + it('orders reactions, reply, copy, edit, delete, then cancel', () => { + const actionSheet = buildMessageActionSheetOptions({ + canReact: true, + canReply: true, + canCopy: true, + canEdit: true, + canDelete: true, + }); + + expect(actionSheet.options).toEqual([ + '👍', + '❤️', + '😂', + '🎉', + 'More reactions', + 'Reply', + 'Copy', + 'Edit', + 'Delete', + 'Cancel', + ]); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/message-actions.ts b/apps/mobile/src/components/kilo-chat/message-actions.ts new file mode 100644 index 0000000000..7c5b0ae6b1 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-actions.ts @@ -0,0 +1,83 @@ +const FIRST_REACTION_EMOJIS = ['👍', '❤️', '😂', '🎉'] as const; + +type ReactionEmoji = (typeof FIRST_REACTION_EMOJIS)[number]; + +type MessageAction = + | { kind: 'reaction'; label: string; emoji: ReactionEmoji } + | { kind: 'more-reactions'; label: 'More reactions' } + | { kind: 'reply'; label: 'Reply' } + | { kind: 'copy'; label: 'Copy' } + | { kind: 'edit'; label: 'Edit' } + | { kind: 'delete'; label: 'Delete' } + | { kind: 'cancel'; label: 'Cancel' }; + +type BuildMessageActionSheetOptionsInput = { + canReact: boolean; + canReply: boolean; + canCopy: boolean; + canEdit: boolean; + canDelete: boolean; + isPendingMessage?: boolean; +}; + +export function buildMessageActionSheetOptions({ + canReact, + canReply, + canCopy, + canEdit, + canDelete, + isPendingMessage = false, +}: BuildMessageActionSheetOptionsInput): { + actions: MessageAction[]; + options: string[]; + cancelButtonIndex: number; + destructiveButtonIndex?: number; +} { + const actions: MessageAction[] = []; + const canUseApiBackedActions = !isPendingMessage; + if (canUseApiBackedActions && canReact) { + for (const emoji of FIRST_REACTION_EMOJIS) { + actions.push({ kind: 'reaction', label: emoji, emoji }); + } + actions.push({ kind: 'more-reactions', label: 'More reactions' }); + } + if (canUseApiBackedActions && canReply) { + actions.push({ kind: 'reply', label: 'Reply' }); + } + if (canCopy) { + actions.push({ kind: 'copy', label: 'Copy' }); + } + if (canUseApiBackedActions && canEdit) { + actions.push({ kind: 'edit', label: 'Edit' }); + } + if (canUseApiBackedActions && canDelete) { + actions.push({ kind: 'delete', label: 'Delete' }); + } + actions.push({ kind: 'cancel', label: 'Cancel' }); + + const options = actions.map(action => action.label); + const deleteButtonIndex = options.indexOf('Delete'); + const destructiveButtonIndex = deleteButtonIndex === -1 ? undefined : deleteButtonIndex; + return { + actions, + options, + cancelButtonIndex: options.length - 1, + ...(destructiveButtonIndex !== undefined && { destructiveButtonIndex }), + }; +} + +export function getSelectedMessageAction( + actionSheet: ReturnType, + index: number | undefined +): Exclude | null { + if (index === undefined || index === actionSheet.cancelButtonIndex) { + return null; + } + + const action = actionSheet.actions[index]; + if (!action || action.kind === 'cancel') { + return null; + } + + return action; +} diff --git a/apps/mobile/src/components/kilo-chat/message-bubble.tsx b/apps/mobile/src/components/kilo-chat/message-bubble.tsx new file mode 100644 index 0000000000..ae18805cbe --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-bubble.tsx @@ -0,0 +1,306 @@ +import { type ExecApprovalDecision, type Message } from '@kilocode/kilo-chat'; +import { AlertCircle, CheckCircle2, Reply, XCircle } from 'lucide-react-native'; +import { memo } from 'react'; +import { Pressable, View } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import { scheduleOnRN } from 'react-native-worklets'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withSequence, + withTiming, +} from 'react-native-reanimated'; + +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { cn } from '@/lib/utils'; +import { + getSwipeReplyActiveOffsetX, + resolveLongPressFeedback, + shouldStartReplyFromSwipe, + SWIPE_REPLY_DISTANCE, + SWIPE_REPLY_MAX_TRANSLATE, +} from './message-gesture-state'; +import { MessageMarkdown } from './message-markdown'; +import { + getDeliveryFailureLabel, + getReplyPreviewText, + isMessageEdited, + type ReplyPreviewSource, +} from './message-presentation'; +import { MessageReactionPills } from './message-reaction-pills'; + +type Props = { + message: Message; + currentUserId: string | null; + isFromMe: boolean; + showAuthor: boolean; + authorLabel: string; + pendingActionGroupId: string | null; + replyToMessage?: ReplyPreviewSource | null; + onExecuteAction: (message: Message, groupId: string, value: ExecApprovalDecision) => void; + onReactionPress: (message: Message, emoji: string) => void; + onLongPress?: (m: Message) => void; + onSwipeReply?: (m: Message) => void; +}; + +function formatTimestamp(ms: number): string { + return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); +} + +function actionStyleToVariant( + style: 'primary' | 'danger' | 'secondary' +): 'default' | 'destructive' | 'secondary' { + if (style === 'danger') { + return 'destructive'; + } + if (style === 'secondary') { + return 'secondary'; + } + return 'default'; +} + +function MessageBubbleComponent({ + message, + currentUserId, + isFromMe, + showAuthor, + authorLabel, + pendingActionGroupId, + replyToMessage, + onExecuteAction, + onReactionPress, + onLongPress, + onSwipeReply, +}: Props) { + const colors = useThemeColors(); + const isPending = message.id.startsWith('pending-'); + const timestamp = message.clientUpdatedAt ?? message.updatedAt; + const edited = isMessageEdited(message); + const swipeX = useSharedValue(0); + const replyProgress = useSharedValue(0); + const pressScale = useSharedValue(1); + const longPressHighlight = useSharedValue(0); + const canSwipeReply = + onSwipeReply !== undefined && !isPending && !message.deleted && !message.deliveryFailed; + + function handleExecuteAction(groupId: string, value: ExecApprovalDecision) { + onExecuteAction(message, groupId, value); + } + + function handleSwipeReply() { + onSwipeReply?.(message); + } + + function handlePressIn() { + const feedback = resolveLongPressFeedback({ pressed: true, longPressed: false }); + pressScale.value = withTiming(feedback.scale, { + duration: 120, + easing: Easing.out(Easing.cubic), + }); + } + + function handlePressOut() { + const feedback = resolveLongPressFeedback({ pressed: false, longPressed: false }); + pressScale.value = withTiming(feedback.scale, { + duration: 160, + easing: Easing.out(Easing.cubic), + }); + longPressHighlight.value = withTiming(feedback.highlightOpacity, { duration: 180 }); + } + + function handleLongPress() { + const feedback = resolveLongPressFeedback({ pressed: true, longPressed: true }); + pressScale.value = withSequence( + withTiming(feedback.scale, { duration: 90, easing: Easing.out(Easing.cubic) }), + withTiming(1, { duration: 180, easing: Easing.out(Easing.cubic) }) + ); + longPressHighlight.value = withSequence( + withTiming(feedback.highlightOpacity, { duration: 90 }), + withTiming(0, { duration: 260 }) + ); + onLongPress?.(message); + } + + // eslint-disable-next-line new-cap -- RNGH's gesture builder API is Gesture.Pan(). + const swipeGesture = Gesture.Pan() + .activeOffsetX(getSwipeReplyActiveOffsetX()) + .onUpdate(event => { + if (!canSwipeReply) { + return; + } + const nextX = Math.max(Math.min(event.translationX, 0), -SWIPE_REPLY_MAX_TRANSLATE); + swipeX.value = nextX; + replyProgress.value = Math.min(Math.abs(nextX) / SWIPE_REPLY_DISTANCE, 1); + }) + .onEnd(event => { + const shouldReply = shouldStartReplyFromSwipe({ + canReply: canSwipeReply, + translationX: event.translationX, + velocityX: event.velocityX, + }); + if (shouldReply) { + scheduleOnRN(handleSwipeReply); + } + swipeX.value = withTiming(0, { duration: 180, easing: Easing.out(Easing.cubic) }); + replyProgress.value = withTiming(0, { duration: 140 }); + }) + .onFinalize(() => { + swipeX.value = withTiming(0, { duration: 180, easing: Easing.out(Easing.cubic) }); + replyProgress.value = withTiming(0, { duration: 140 }); + }); + + const swipeStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: swipeX.value }, { scale: pressScale.value }], + })); + const replyHintStyle = useAnimatedStyle(() => ({ + opacity: replyProgress.value, + transform: [{ scale: 0.85 + replyProgress.value * 0.15 }], + })); + const longPressHighlightStyle = useAnimatedStyle(() => ({ + opacity: longPressHighlight.value, + })); + + const textColor = isFromMe ? 'text-white' : 'text-foreground'; + const deliveryFailureLabel = getDeliveryFailureLabel(message); + + return ( + + + {canSwipeReply && ( + + + + + + )} + + + {showAuthor && ( + + {authorLabel} + {timestamp !== null && ( + + {formatTimestamp(timestamp)} + + )} + + )} + + + + {message.deleted ? ( + [deleted message] + ) : ( + <> + {replyToMessage && ( + + + {getReplyPreviewText(replyToMessage)} + + + )} + {message.content.map((block, index) => { + if (block.type === 'text') { + return ; + } + + // block.type === 'actions' + if (block.resolved) { + const resolvedAction = block.actions.find( + action => action.value === block.resolved?.value + ); + const label = resolvedAction?.label ?? block.resolved.value; + const Icon = block.resolved.value.startsWith('allow') ? CheckCircle2 : XCircle; + return ( + + + {label} + + ); + } + + return ( + + {block.actions.map(action => ( + + ))} + + ); + })} + {deliveryFailureLabel && ( + + + + {deliveryFailureLabel} + + + )} + + )} + + {!showAuthor && timestamp !== null && ( + + {formatTimestamp(timestamp)} + {edited ? ' (edited)' : ''} + + )} + + + + + + + ); +} + +export const MessageBubble = memo(MessageBubbleComponent); diff --git a/apps/mobile/src/components/kilo-chat/message-gesture-state.test.ts b/apps/mobile/src/components/kilo-chat/message-gesture-state.test.ts new file mode 100644 index 0000000000..93e1092e7f --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-gesture-state.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { + getSwipeReplyActiveOffsetX, + resolveLongPressFeedback, + shouldStartReplyFromSwipe, +} from './message-gesture-state'; + +describe('getSwipeReplyActiveOffsetX', () => { + it('activates the message gesture only for left swipes', () => { + expect(getSwipeReplyActiveOffsetX()).toEqual([-12, Number.MAX_SAFE_INTEGER]); + }); +}); + +describe('shouldStartReplyFromSwipe', () => { + it('starts reply on a committed left swipe when reply is available', () => { + expect( + shouldStartReplyFromSwipe({ + canReply: true, + translationX: -64, + velocityX: -120, + }) + ).toBe(true); + }); + + it('ignores short left drags and right swipes', () => { + expect( + shouldStartReplyFromSwipe({ + canReply: true, + translationX: -24, + velocityX: -100, + }) + ).toBe(false); + expect( + shouldStartReplyFromSwipe({ + canReply: true, + translationX: 72, + velocityX: 500, + }) + ).toBe(false); + }); + + it('ignores swipe gestures when the message cannot be replied to', () => { + expect( + shouldStartReplyFromSwipe({ + canReply: false, + translationX: -80, + velocityX: -700, + }) + ).toBe(false); + }); +}); + +describe('resolveLongPressFeedback', () => { + it('keeps press and long-press feedback subtle', () => { + expect(resolveLongPressFeedback({ pressed: false, longPressed: false })).toEqual({ + scale: 1, + highlightOpacity: 0, + }); + expect(resolveLongPressFeedback({ pressed: true, longPressed: false })).toEqual({ + scale: 0.985, + highlightOpacity: 0, + }); + expect(resolveLongPressFeedback({ pressed: true, longPressed: true })).toEqual({ + scale: 0.97, + highlightOpacity: 1, + }); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/message-gesture-state.ts b/apps/mobile/src/components/kilo-chat/message-gesture-state.ts new file mode 100644 index 0000000000..17d9b3bf43 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-gesture-state.ts @@ -0,0 +1,68 @@ +export const SWIPE_REPLY_DISTANCE = 56; +export const SWIPE_REPLY_FAST_DISTANCE = 24; +export const SWIPE_REPLY_FAST_VELOCITY = -650; +export const SWIPE_REPLY_MAX_TRANSLATE = 72; +export const SWIPE_REPLY_ACTIVATION_DISTANCE = 12; +export const LONG_PRESS_FEEDBACK_PRESS_SCALE = 0.985; +export const LONG_PRESS_FEEDBACK_ACTIVE_SCALE = 0.97; +export const LONG_PRESS_FEEDBACK_HIGHLIGHT_OPACITY = 1; + +type SwipeReplyInput = { + canReply: boolean; + translationX: number; + velocityX: number; +}; + +type LongPressFeedbackInput = { + pressed: boolean; + longPressed: boolean; +}; + +type LongPressFeedback = { + scale: number; + highlightOpacity: number; +}; + +export function getSwipeReplyActiveOffsetX(): [number, number] { + return [-SWIPE_REPLY_ACTIVATION_DISTANCE, Number.MAX_SAFE_INTEGER]; +} + +export function shouldStartReplyFromSwipe({ + canReply, + translationX, + velocityX, +}: SwipeReplyInput): boolean { + 'worklet'; + + if (!canReply || translationX >= 0) { + return false; + } + + const distance = Math.abs(translationX); + return ( + distance >= SWIPE_REPLY_DISTANCE || + (distance >= SWIPE_REPLY_FAST_DISTANCE && velocityX <= SWIPE_REPLY_FAST_VELOCITY) + ); +} + +export function resolveLongPressFeedback({ + pressed, + longPressed, +}: LongPressFeedbackInput): LongPressFeedback { + if (longPressed) { + return { + scale: LONG_PRESS_FEEDBACK_ACTIVE_SCALE, + highlightOpacity: LONG_PRESS_FEEDBACK_HIGHLIGHT_OPACITY, + }; + } + if (pressed) { + return { + scale: LONG_PRESS_FEEDBACK_PRESS_SCALE, + highlightOpacity: 0, + }; + } + return { + scale: 1, + highlightOpacity: 0, + }; +} diff --git a/apps/mobile/src/components/kilo-chat/message-history-state.test.ts b/apps/mobile/src/components/kilo-chat/message-history-state.test.ts new file mode 100644 index 0000000000..67922443a7 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-history-state.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { getMessageHistoryContentState } from './message-history-state'; + +describe('getMessageHistoryContentState', () => { + it('blocks the composer while the initial history is pending or errored', () => { + expect(getMessageHistoryContentState({ isPending: true, isError: false, hasData: false })).toBe( + 'loading' + ); + + expect(getMessageHistoryContentState({ isPending: false, isError: true, hasData: false })).toBe( + 'error' + ); + }); + + it('allows the chat surface after the initial history loads', () => { + expect(getMessageHistoryContentState({ isPending: false, isError: false, hasData: true })).toBe( + 'ready' + ); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/message-history-state.ts b/apps/mobile/src/components/kilo-chat/message-history-state.ts new file mode 100644 index 0000000000..dae35d0d87 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-history-state.ts @@ -0,0 +1,22 @@ +type MessageHistoryContentState = 'loading' | 'error' | 'ready'; + +export function getMessageHistoryContentState({ + isPending, + isError, + hasData, +}: { + isPending: boolean; + isError: boolean; + hasData: boolean; +}): MessageHistoryContentState { + if (isPending) { + return 'loading'; + } + if (isError) { + return 'error'; + } + if (!hasData) { + return 'loading'; + } + return 'ready'; +} diff --git a/apps/mobile/src/components/kilo-chat/message-input-layout.test.ts b/apps/mobile/src/components/kilo-chat/message-input-layout.test.ts new file mode 100644 index 0000000000..5212e48f58 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-input-layout.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { + MESSAGE_INPUT_BORDER_WIDTH, + MESSAGE_INPUT_LINE_HEIGHT, + MESSAGE_INPUT_MIN_HEIGHT, + messageInputTextStyle, + resolveMessageInputBottomPadding, +} from './message-input-layout'; + +describe('message input layout', () => { + it('centers a single text line inside the bordered composer input', () => { + const expectedPadding = + (MESSAGE_INPUT_MIN_HEIGHT - MESSAGE_INPUT_LINE_HEIGHT - MESSAGE_INPUT_BORDER_WIDTH * 2) / 2; + + expect(messageInputTextStyle).toMatchObject({ + includeFontPadding: false, + lineHeight: MESSAGE_INPUT_LINE_HEIGHT, + paddingBottom: expectedPadding, + paddingTop: expectedPadding, + textAlignVertical: 'top', + }); + }); + + it('keeps composer bottom padding constant across safe-area insets', () => { + expect(resolveMessageInputBottomPadding()).toBe(8); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/message-input-layout.ts b/apps/mobile/src/components/kilo-chat/message-input-layout.ts new file mode 100644 index 0000000000..3304933727 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-input-layout.ts @@ -0,0 +1,24 @@ +import { type TextStyle } from 'react-native'; + +export const MESSAGE_INPUT_MIN_HEIGHT = 40; +export const MESSAGE_INPUT_MAX_HEIGHT = 128; +export const MESSAGE_INPUT_LINE_HEIGHT = 20; +export const MESSAGE_INPUT_BORDER_WIDTH = 1; +export const MESSAGE_INPUT_BOTTOM_CLEARANCE = 8; + +const MESSAGE_INPUT_VERTICAL_PADDING = + (MESSAGE_INPUT_MIN_HEIGHT - MESSAGE_INPUT_LINE_HEIGHT - MESSAGE_INPUT_BORDER_WIDTH * 2) / 2; + +export const messageInputTextStyle = { + includeFontPadding: false, + lineHeight: MESSAGE_INPUT_LINE_HEIGHT, + maxHeight: MESSAGE_INPUT_MAX_HEIGHT, + minHeight: MESSAGE_INPUT_MIN_HEIGHT, + paddingBottom: MESSAGE_INPUT_VERTICAL_PADDING, + paddingTop: MESSAGE_INPUT_VERTICAL_PADDING, + textAlignVertical: 'top', +} satisfies TextStyle; + +export function resolveMessageInputBottomPadding(): number { + return MESSAGE_INPUT_BOTTOM_CLEARANCE; +} diff --git a/apps/mobile/src/components/kilo-chat/message-input-state.test.ts b/apps/mobile/src/components/kilo-chat/message-input-state.test.ts new file mode 100644 index 0000000000..b12b83988b --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-input-state.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest'; +import { MESSAGE_TEXT_MAX_CHARS } from '@kilocode/kilo-chat'; + +import { + applyMessageInputTextChange, + shouldClearSubmittedDraft, + shouldShowMessageInputCounter, + submitMessageInputDraft, +} from './message-input-state'; + +describe('message input typing behavior', () => { + it('sends typing notifications on text changes without preventing normal send', () => { + const valueRef = { current: '' }; + const canSendValues: boolean[] = []; + const sentMessages: { text: string; replyTo?: string }[] = []; + let cleared = false; + let typingCount = 0; + + applyMessageInputTextChange({ + text: ' hello ', + valueRef, + setCanSend: canSend => { + canSendValues.push(canSend); + }, + onTyping: () => { + typingCount += 1; + }, + }); + + const submitted = submitMessageInputDraft({ + valueRef, + replyingToMessageId: 'reply-1', + onSend: (text, replyTo) => { + sentMessages.push({ text, replyTo }); + }, + clearInput: () => { + cleared = true; + }, + setCanSend: canSend => { + canSendValues.push(canSend); + }, + }); + + expect(typingCount).toBe(1); + expect(submitted).toEqual({ text: 'hello', replyingToMessageId: 'reply-1' }); + expect(sentMessages).toEqual([{ text: 'hello', replyTo: 'reply-1' }]); + expect(cleared).toBe(false); + expect(valueRef.current).toBe(' hello '); + expect(canSendValues).toEqual([true]); + }); + + it('keeps over-limit drafts intact and does not submit them', () => { + const overLimitText = 'x'.repeat(MESSAGE_TEXT_MAX_CHARS + 1); + const valueRef = { current: '' }; + const canSendValues: boolean[] = []; + const sentMessages: string[] = []; + let cleared = false; + + applyMessageInputTextChange({ + text: overLimitText, + valueRef, + setCanSend: canSend => { + canSendValues.push(canSend); + }, + }); + + const submitted = submitMessageInputDraft({ + valueRef, + onSend: text => { + sentMessages.push(text); + }, + clearInput: () => { + cleared = true; + }, + setCanSend: canSend => { + canSendValues.push(canSend); + }, + }); + + expect(submitted).toBeNull(); + expect(sentMessages).toEqual([]); + expect(cleared).toBe(false); + expect(valueRef.current).toBe(overLimitText); + expect(canSendValues).toEqual([false]); + }); + + it('leaves edit drafts intact when the caller controls clearing', () => { + const valueRef = { current: ' edited draft ' }; + const canSendValues: boolean[] = []; + const sentMessages: string[] = []; + let cleared = false; + + const submitted = submitMessageInputDraft({ + valueRef, + onSend: text => { + sentMessages.push(text); + }, + clearInput: () => { + cleared = true; + }, + setCanSend: canSend => { + canSendValues.push(canSend); + }, + clearOnSubmit: false, + }); + + expect(submitted).toEqual({ text: 'edited draft', replyingToMessageId: undefined }); + expect(sentMessages).toEqual(['edited draft']); + expect(cleared).toBe(false); + expect(valueRef.current).toBe(' edited draft '); + expect(canSendValues).toEqual([]); + }); + + it('lets edit callers clear drafts after successful mutation', () => { + const valueRef = { current: ' edited draft ' }; + const canSendValues: boolean[] = []; + const successControls: { clearDraft?: () => boolean } = {}; + let cleared = false; + + const submitted = submitMessageInputDraft({ + valueRef, + onSend: (_text, _replyTo, controls) => { + if (!controls) { + throw new Error('expected submit controls'); + } + successControls.clearDraft = controls.clearDraft; + }, + clearInput: () => { + cleared = true; + }, + setCanSend: canSend => { + canSendValues.push(canSend); + }, + clearOnSubmit: false, + }); + + const clearDraft = successControls.clearDraft; + if (!clearDraft) { + throw new Error('expected submit controls'); + } + clearDraft(); + + expect(submitted).toEqual({ text: 'edited draft', replyingToMessageId: undefined }); + expect(cleared).toBe(true); + expect(valueRef.current).toBe(''); + expect(canSendValues).toEqual([false]); + }); + + it('shows the character counter at eighty percent of the text limit', () => { + expect(shouldShowMessageInputCounter('x'.repeat(MESSAGE_TEXT_MAX_CHARS * 0.8 - 1))).toBe(false); + expect(shouldShowMessageInputCounter('x'.repeat(MESSAGE_TEXT_MAX_CHARS * 0.8))).toBe(true); + }); + + it('clears a submitted draft only when the visible draft and reply target still match', () => { + const submitted = { text: 'hello', replyingToMessageId: 'reply-1' }; + + expect( + shouldClearSubmittedDraft({ + currentText: 'hello', + currentReplyingToMessageId: 'reply-1', + submitted, + }) + ).toBe(true); + + expect( + shouldClearSubmittedDraft({ + currentText: 'hello again', + currentReplyingToMessageId: 'reply-1', + submitted, + }) + ).toBe(false); + + expect( + shouldClearSubmittedDraft({ + currentText: 'hello', + currentReplyingToMessageId: undefined, + submitted, + }) + ).toBe(false); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/message-input-state.ts b/apps/mobile/src/components/kilo-chat/message-input-state.ts new file mode 100644 index 0000000000..eea82eee49 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-input-state.ts @@ -0,0 +1,104 @@ +import { MESSAGE_TEXT_MAX_CHARS } from '@kilocode/kilo-chat'; + +type DraftRef = { current: string }; + +export type MessageInputSubmitControls = { + clearDraft: () => boolean; +}; + +type SubmittedMessageDraft = { + text: string; + replyingToMessageId?: string; +}; + +function canSubmitDraft(text: string): boolean { + return text.trim().length > 0 && text.length <= MESSAGE_TEXT_MAX_CHARS; +} + +export function shouldShowMessageInputCounter(text: string): boolean { + return text.length >= MESSAGE_TEXT_MAX_CHARS * 0.8; +} + +export function isMessageInputOverLimit(text: string): boolean { + return text.length > MESSAGE_TEXT_MAX_CHARS; +} + +export function shouldClearSubmittedDraft({ + currentText, + currentReplyingToMessageId, + submitted, +}: { + currentText: string; + currentReplyingToMessageId?: string; + submitted: SubmittedMessageDraft; +}): boolean { + return ( + currentText === submitted.text && currentReplyingToMessageId === submitted.replyingToMessageId + ); +} + +export function applyMessageInputTextChange({ + text, + valueRef, + setCanSend, + onTyping, +}: { + text: string; + valueRef: DraftRef; + setCanSend: (canSend: boolean) => void; + onTyping?: () => void; +}) { + valueRef.current = text; + setCanSend(canSubmitDraft(text)); + onTyping?.(); +} + +export function submitMessageInputDraft({ + valueRef, + replyingToMessageId, + onSend, + clearInput, + setCanSend, + getCurrentReplyingToMessageId, + clearOnSubmit = false, +}: { + valueRef: DraftRef; + replyingToMessageId?: string; + onSend: ( + text: string, + inReplyToMessageId?: string, + controls?: MessageInputSubmitControls + ) => void; + clearInput: () => void; + setCanSend: (canSend: boolean) => void; + getCurrentReplyingToMessageId?: () => string | undefined; + clearOnSubmit?: boolean; +}): SubmittedMessageDraft | null { + const draft = valueRef.current; + if (!canSubmitDraft(draft)) { + return null; + } + + const text = draft.trim(); + const submitted: SubmittedMessageDraft = { text, replyingToMessageId }; + const clearDraft = () => { + if ( + !shouldClearSubmittedDraft({ + currentText: valueRef.current.trim(), + currentReplyingToMessageId: getCurrentReplyingToMessageId?.() ?? replyingToMessageId, + submitted, + }) + ) { + return false; + } + valueRef.current = ''; + clearInput(); + setCanSend(false); + return true; + }; + onSend(text, replyingToMessageId, { clearDraft }); + if (clearOnSubmit) { + clearDraft(); + } + return submitted; +} diff --git a/apps/mobile/src/components/kilo-chat/message-input.tsx b/apps/mobile/src/components/kilo-chat/message-input.tsx new file mode 100644 index 0000000000..8f2599ffd5 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-input.tsx @@ -0,0 +1,198 @@ +import { Send, X } from 'lucide-react-native'; +import { useRef, useState } from 'react'; +import { Pressable, TextInput, View } from 'react-native'; +import { type Message, MESSAGE_TEXT_MAX_CHARS } from '@kilocode/kilo-chat'; + +import { Text } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { messageInputTextStyle, resolveMessageInputBottomPadding } from './message-input-layout'; +import { + applyMessageInputTextChange, + isMessageInputOverLimit, + type MessageInputSubmitControls, + shouldShowMessageInputCounter, + submitMessageInputDraft, +} from './message-input-state'; +import { getReplyPreviewText } from './message-presentation'; +import { TypingIndicator } from './typing-indicator'; + +type Props = { + onSend: ( + text: string, + inReplyToMessageId?: string, + controls?: MessageInputSubmitControls + ) => void; + onTyping?: () => void; + disabled?: boolean; + submitDisabled?: boolean; + initialText?: string; + onCancelEdit?: () => void; + replyingTo?: Message | null; + onCancelReply?: () => void; + disabledReason?: string | null; + clearOnSubmit?: boolean; + botName?: string | null; + typingMembers?: Map; +}; + +function resolveSendDisabled({ + canSend, + disabled, + overLimit, +}: { + canSend: boolean; + disabled?: boolean; + overLimit: boolean; +}): boolean { + if (!canSend) { + return true; + } + if (disabled === true) { + return true; + } + return overLimit; +} + +export function MessageInput({ + onSend, + onTyping, + disabled, + submitDisabled, + initialText = '', + onCancelEdit, + replyingTo, + onCancelReply, + disabledReason, + clearOnSubmit, + botName, + typingMembers = new Map(), +}: Props) { + const colors = useThemeColors(); + const valueRef = useRef(initialText); + const [canSend, setCanSend] = useState(initialText.trim().length > 0); + const [draftLength, setDraftLength] = useState(initialText.length); + const inputRef = useRef(null); + const currentReplyingToRef = useRef(replyingTo?.id); + currentReplyingToRef.current = replyingTo?.id; + const overLimit = isMessageInputOverLimit(valueRef.current); + const showCounter = shouldShowMessageInputCounter(valueRef.current); + const sendDisabled = + submitDisabled === true || resolveSendDisabled({ canSend, disabled, overLimit }); + const controlsDisabled = disabled === true || submitDisabled === true; + + const submit = () => { + if (disabled || submitDisabled) { + return; + } + submitMessageInputDraft({ + valueRef, + replyingToMessageId: replyingTo?.id, + onSend, + clearInput: () => { + inputRef.current?.clear(); + setDraftLength(0); + }, + setCanSend, + getCurrentReplyingToMessageId: () => currentReplyingToRef.current, + clearOnSubmit, + }); + }; + + return ( + + {replyingTo && ( + + + Replying to + + {getReplyPreviewText(replyingTo)} + + + + + + + )} + {disabledReason && ( + + {disabledReason} + + )} + + + + 160} + editable={!disabled} + onChangeText={t => { + setDraftLength(t.length); + applyMessageInputTextChange({ + text: t, + valueRef, + setCanSend, + onTyping, + }); + }} + onSubmitEditing={submit} + returnKeyType="send" + submitBehavior="submit" + /> + + {onCancelEdit && ( + + + + )} + + + + + {showCounter ? ( + + + {draftLength}/{MESSAGE_TEXT_MAX_CHARS} + + + ) : null} + + + + ); +} diff --git a/apps/mobile/src/components/kilo-chat/message-list-keyboard-scroll.test.ts b/apps/mobile/src/components/kilo-chat/message-list-keyboard-scroll.test.ts new file mode 100644 index 0000000000..7b9f789b10 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-list-keyboard-scroll.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createMessageListKeyboardScrollScheduler, + createMessageListNewestScrollScheduler, + MESSAGE_LIST_KEYBOARD_SCROLL_RETRY_DELAY_MS, +} from './message-list-keyboard-scroll'; + +describe('message list keyboard scroll scheduler', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('maintains the current viewport by shifting the offset by keyboard height', () => { + vi.useFakeTimers(); + const calls: { animated: boolean; offset: number }[] = []; + const scheduler = createMessageListKeyboardScrollScheduler({ + getScrollOffset: () => 240, + scrollToOffset: params => { + calls.push(params); + }, + }); + + scheduler.schedule(320); + + expect(calls).toEqual([{ animated: true, offset: 560 }]); + + vi.advanceTimersByTime(MESSAGE_LIST_KEYBOARD_SCROLL_RETRY_DELAY_MS); + + expect(calls).toEqual([ + { animated: true, offset: 560 }, + { animated: true, offset: 560 }, + ]); + }); + + it('scrolls to the newest message immediately and after layout settles', () => { + vi.useFakeTimers(); + const calls: { animated: boolean }[] = []; + const scheduler = createMessageListNewestScrollScheduler({ + scrollToEnd: params => { + calls.push(params); + }, + }); + + scheduler.schedule(); + + expect(calls).toEqual([{ animated: true }]); + + vi.advanceTimersByTime(MESSAGE_LIST_KEYBOARD_SCROLL_RETRY_DELAY_MS); + + expect(calls).toEqual([{ animated: true }, { animated: true }]); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/message-list-keyboard-scroll.ts b/apps/mobile/src/components/kilo-chat/message-list-keyboard-scroll.ts new file mode 100644 index 0000000000..fcbd5a3447 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-list-keyboard-scroll.ts @@ -0,0 +1,79 @@ +export const MESSAGE_LIST_KEYBOARD_SCROLL_RETRY_DELAY_MS = 80; + +type ScrollToOffsetParams = { + animated: boolean; + offset: number; +}; + +type ScrollToEndParams = { + animated: boolean; +}; + +type MessageListKeyboardScrollSchedulerParams = { + getScrollOffset: () => number; + scrollToOffset: (params: ScrollToOffsetParams) => void; +}; + +type MessageListNewestScrollSchedulerParams = { + scrollToEnd: (params: ScrollToEndParams) => void; +}; + +export function createMessageListKeyboardScrollScheduler({ + getScrollOffset, + scrollToOffset, +}: MessageListKeyboardScrollSchedulerParams) { + let retryTimeout: ReturnType | null = null; + + const clearRetry = () => { + if (retryTimeout !== null) { + clearTimeout(retryTimeout); + retryTimeout = null; + } + }; + + const scrollToMaintainedPosition = (offset: number) => { + scrollToOffset({ animated: true, offset }); + }; + + return { + cancel: clearRetry, + schedule: (keyboardHeight: number) => { + clearRetry(); + const maintainedOffset = getScrollOffset() + keyboardHeight; + scrollToMaintainedPosition(maintainedOffset); + retryTimeout = setTimeout(() => { + retryTimeout = null; + scrollToMaintainedPosition(maintainedOffset); + }, MESSAGE_LIST_KEYBOARD_SCROLL_RETRY_DELAY_MS); + }, + }; +} + +export function createMessageListNewestScrollScheduler({ + scrollToEnd, +}: MessageListNewestScrollSchedulerParams) { + let retryTimeout: ReturnType | null = null; + + const clearRetry = () => { + if (retryTimeout !== null) { + clearTimeout(retryTimeout); + retryTimeout = null; + } + }; + + const scrollToNewest = () => { + scrollToEnd({ animated: true }); + }; + + return { + cancel: clearRetry, + schedule: () => { + clearRetry(); + scrollToNewest(); + retryTimeout = setTimeout(() => { + retryTimeout = null; + scrollToNewest(); + }, MESSAGE_LIST_KEYBOARD_SCROLL_RETRY_DELAY_MS); + }, + }; +} diff --git a/apps/mobile/src/components/kilo-chat/message-list-scroll-state.test.ts b/apps/mobile/src/components/kilo-chat/message-list-scroll-state.test.ts new file mode 100644 index 0000000000..1c6008fb24 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-list-scroll-state.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import { + isMessageListAtBottom, + messageListNewestScrollKey, + shouldScrollToNewestAfterMessagesChange, +} from './message-list-scroll-state'; +import { type Message } from '@kilocode/kilo-chat'; + +const newestMessage = { + id: 'message-2', + senderId: 'bot-1', + content: [{ type: 'text', text: 'first draft' }], + inReplyToMessageId: null, + replyTo: null, + updatedAt: 10, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions: [], +} satisfies Message; + +describe('message list scroll state', () => { + it('detects whether the visible viewport is at the bottom', () => { + expect( + isMessageListAtBottom({ + contentHeight: 1200, + viewportHeight: 500, + offsetY: 700, + }) + ).toBe(true); + + expect( + isMessageListAtBottom({ + contentHeight: 1200, + viewportHeight: 500, + offsetY: 650, + }) + ).toBe(false); + }); + + it('scrolls after newest-message changes only when the user was already at bottom', () => { + expect( + shouldScrollToNewestAfterMessagesChange({ + newestMessageKey: 'message-2', + previousNewestMessageKey: 'message-1', + wasAtBottom: true, + }) + ).toBe(true); + + expect( + shouldScrollToNewestAfterMessagesChange({ + newestMessageKey: 'message-2', + previousNewestMessageKey: 'message-1', + wasAtBottom: false, + }) + ).toBe(false); + + expect( + shouldScrollToNewestAfterMessagesChange({ + newestMessageKey: 'message-2', + previousNewestMessageKey: 'message-2', + wasAtBottom: true, + }) + ).toBe(false); + }); + + it('changes the newest scroll key when the newest message is edited', () => { + expect(messageListNewestScrollKey(newestMessage)).not.toBe( + messageListNewestScrollKey({ + ...newestMessage, + content: [{ type: 'text', text: 'edited draft' }], + updatedAt: 20, + }) + ); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/message-list-scroll-state.ts b/apps/mobile/src/components/kilo-chat/message-list-scroll-state.ts new file mode 100644 index 0000000000..182f8193a9 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-list-scroll-state.ts @@ -0,0 +1,41 @@ +import { type Message } from '@kilocode/kilo-chat'; + +const MESSAGE_LIST_BOTTOM_THRESHOLD_PX = 24; + +export function isMessageListAtBottom({ + contentHeight, + viewportHeight, + offsetY, +}: { + contentHeight: number; + viewportHeight: number; + offsetY: number; +}): boolean { + return offsetY + viewportHeight >= contentHeight - MESSAGE_LIST_BOTTOM_THRESHOLD_PX; +} + +export function shouldScrollToNewestAfterMessagesChange({ + newestMessageKey, + previousNewestMessageKey, + wasAtBottom, +}: { + newestMessageKey: string | null; + previousNewestMessageKey: string | null; + wasAtBottom: boolean; +}): boolean { + return newestMessageKey !== null && newestMessageKey !== previousNewestMessageKey && wasAtBottom; +} + +export function messageListNewestScrollKey(message: Message | undefined): string | null { + if (!message) { + return null; + } + return JSON.stringify({ + id: message.id, + content: message.content, + updatedAt: message.updatedAt, + clientUpdatedAt: message.clientUpdatedAt, + deleted: message.deleted, + deliveryFailed: message.deliveryFailed, + }); +} diff --git a/apps/mobile/src/components/kilo-chat/message-list.tsx b/apps/mobile/src/components/kilo-chat/message-list.tsx new file mode 100644 index 0000000000..a4b4d34a1d --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-list.tsx @@ -0,0 +1,180 @@ +import { FlashList, type FlashListRef } from '@shopify/flash-list'; +import { type ExecApprovalDecision, type Message } from '@kilocode/kilo-chat'; +import { type PendingAction, pendingActionGroupIdForMessage } from '@kilocode/kilo-chat-hooks'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { Keyboard, type NativeScrollEvent, type NativeSyntheticEvent, View } from 'react-native'; + +import { MessageBubble } from '@/components/kilo-chat/message-bubble'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + createMessageListKeyboardScrollScheduler, + createMessageListNewestScrollScheduler, +} from './message-list-keyboard-scroll'; +import { + isMessageListAtBottom, + messageListNewestScrollKey, + shouldScrollToNewestAfterMessagesChange, +} from './message-list-scroll-state'; +import { type MessageAuthorMember, resolveMessageAuthorLabel } from './message-presentation'; + +type Props = { + messages: Message[]; + currentUserId: string | null; + members?: readonly MessageAuthorMember[]; + botName?: string | null; + fetchOlder?: () => void; + isFetchingOlder: boolean; + pendingAction: PendingAction | null; + scrollToNewestRequest: number; + onExecuteAction: (message: Message, groupId: string, value: ExecApprovalDecision) => void; + onReactionPress: (message: Message, emoji: string) => void; + onLongPressMessage?: (m: Message) => void; + onSwipeReplyMessage?: (m: Message) => void; +}; + +export function MessageList({ + messages, + currentUserId, + members, + botName, + fetchOlder, + isFetchingOlder, + pendingAction, + scrollToNewestRequest, + onExecuteAction, + onReactionPress, + onLongPressMessage, + onSwipeReplyMessage, +}: Props) { + const listRef = useRef>(null); + const scrollOffsetRef = useRef(0); + const initialNewestMessage = messages.at(-1); + const newestMessageKeyRef = useRef(messageListNewestScrollKey(initialNewestMessage)); + const isAtBottomRef = useRef(true); + const scrollToNewestRequestRef = useRef(scrollToNewestRequest); + const keyboardScrollScheduler = useMemo( + () => + createMessageListKeyboardScrollScheduler({ + getScrollOffset: () => scrollOffsetRef.current, + scrollToOffset: params => { + listRef.current?.scrollToOffset(params); + }, + }), + [] + ); + const newestScrollScheduler = useMemo( + () => + createMessageListNewestScrollScheduler({ + scrollToEnd: params => { + listRef.current?.scrollToEnd(params); + }, + }), + [] + ); + // useMessages returns messages oldest-to-newest. + // FlashList v2 does not support `inverted`; instead we use maintainVisibleContentPosition + // with startRenderingFromBottom, which expects chronological order. + const chronological = messages; + const newestMessage = chronological.at(-1); + const messageMap = useMemo( + () => new Map(chronological.map(message => [message.id, message])), + [chronological] + ); + const scrollToNewest = useCallback(() => { + isAtBottomRef.current = true; + newestScrollScheduler.schedule(); + }, [newestScrollScheduler]); + + const handleScroll = useCallback((event: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + scrollOffsetRef.current = contentOffset.y; + isAtBottomRef.current = isMessageListAtBottom({ + contentHeight: contentSize.height, + viewportHeight: layoutMeasurement.height, + offsetY: contentOffset.y, + }); + }, []); + + useEffect(() => { + const subscription = Keyboard.addListener('keyboardDidShow', event => { + keyboardScrollScheduler.schedule(event.endCoordinates.height); + }); + + return () => { + subscription.remove(); + keyboardScrollScheduler.cancel(); + newestScrollScheduler.cancel(); + }; + }, [keyboardScrollScheduler, newestScrollScheduler]); + + useEffect(() => { + const newestMessageKey = messageListNewestScrollKey(newestMessage); + const shouldScroll = shouldScrollToNewestAfterMessagesChange({ + newestMessageKey, + previousNewestMessageKey: newestMessageKeyRef.current, + wasAtBottom: isAtBottomRef.current, + }); + newestMessageKeyRef.current = newestMessageKey; + if (shouldScroll) { + scrollToNewest(); + } + }, [newestMessage, scrollToNewest]); + + useEffect(() => { + if (scrollToNewestRequestRef.current === scrollToNewestRequest) { + return; + } + scrollToNewestRequestRef.current = scrollToNewestRequest; + scrollToNewest(); + }, [scrollToNewest, scrollToNewestRequest]); + + return ( + { + // In chronological order, the previous message in time is data[index - 1]. + // showAuthor is true when the sender changes relative to the prior message, + // or when this is the oldest message (index 0). + const previousItem = chronological[index - 1]; + const showAuthor = previousItem === undefined || previousItem.senderId !== item.senderId; + + return ( + + ); + }} + keyExtractor={item => item.id} + onScroll={handleScroll} + scrollEventThrottle={16} + onStartReached={fetchOlder} + onStartReachedThreshold={0.5} + maintainVisibleContentPosition={{ + // Start rendering from the bottom so the newest message is visible on first render. + startRenderingFromBottom: true, + }} + ListHeaderComponent={ + isFetchingOlder ? ( + + + + ) : null + } + /> + ); +} diff --git a/apps/mobile/src/components/kilo-chat/message-markdown.tsx b/apps/mobile/src/components/kilo-chat/message-markdown.tsx new file mode 100644 index 0000000000..5a82032a37 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-markdown.tsx @@ -0,0 +1,34 @@ +import { Text } from '@/components/ui/text'; + +import { MarkdownText } from '../agents/markdown-text'; +import { isMessageTextSelectionEnabled } from './message-presentation'; + +type MessageMarkdownProps = { + text: string; + isFromMe: boolean; +}; + +export function MessageMarkdown({ text, isFromMe }: Readonly) { + if (text.trim().length === 0) { + return null; + } + + try { + return ( + + ); + } catch { + return ( + + {text} + + ); + } +} diff --git a/apps/mobile/src/components/kilo-chat/message-presentation.test.ts b/apps/mobile/src/components/kilo-chat/message-presentation.test.ts new file mode 100644 index 0000000000..488bbda49a --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-presentation.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createMessageRequestSchema, type Message } from '@kilocode/kilo-chat'; + +import { + buildSendMessageVariables, + canShowReactionPills, + canToggleReaction, + createSendMessageClientId, + getDeliveryFailureLabel, + getReplyPreviewText, + isMessageEdited, + isMessageTextSelectionEnabled, + resolveMessageAuthorLabel, +} from './message-presentation'; + +vi.mock('expo-crypto', () => ({ + getRandomValues: (typedArray: Uint8Array) => { + typedArray[0] = 128; + return typedArray; + }, +})); + +vi.mock('ulid', () => ({ + ulid: (_seedTime?: number, prng?: () => number) => { + if (!prng) { + throw new Error('missing explicit PRNG'); + } + prng(); + return '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + }, +})); + +function message(overrides: Partial = {}): Message { + return { + id: 'message-1', + senderId: 'user-1', + content: [{ type: 'text', text: 'hello' }], + inReplyToMessageId: null, + replyTo: null, + updatedAt: null, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions: [], + ...overrides, + }; +} + +describe('buildSendMessageVariables', () => { + it('creates client ids without relying on ULID PRNG auto-detection', () => { + expect(createSendMessageClientId()).toBe('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + }); + + it('builds variables accepted by the create message request schema', () => { + const variables = buildSendMessageVariables({ + conversationId: '01ARZ3NDEKTSV4RRFFQ69G5FAV', + text: 'mobile message', + clientId: createSendMessageClientId(), + }); + + expect(createMessageRequestSchema.safeParse(variables).success).toBe(true); + }); + + it('includes inReplyToMessageId when sending a reply', () => { + expect( + buildSendMessageVariables({ + conversationId: 'conversation-1', + text: 'reply body', + clientId: 'client-1', + inReplyToMessageId: 'parent-1', + }) + ).toEqual({ + conversationId: 'conversation-1', + content: [{ type: 'text', text: 'reply body' }], + clientId: 'client-1', + inReplyToMessageId: 'parent-1', + }); + }); +}); + +describe('getReplyPreviewText', () => { + it('uses parent text for a reply preview', () => { + expect(getReplyPreviewText(message({ content: [{ type: 'text', text: 'parent text' }] }))).toBe( + 'parent text' + ); + }); + + it('uses a deleted-message label for deleted parents', () => { + expect(getReplyPreviewText(message({ deleted: true }))).toBe('[deleted message]'); + }); + + it('uses unloaded parent snapshot text for a reply preview', () => { + expect( + getReplyPreviewText({ + messageId: 'parent-1', + senderId: 'user-1', + deleted: false, + previewText: 'snapshot parent text', + }) + ).toBe('snapshot parent text'); + }); +}); + +describe('getDeliveryFailureLabel', () => { + it('returns a visible failure label for failed delivery messages', () => { + expect(getDeliveryFailureLabel(message({ deliveryFailed: true }))).toBe('Not delivered'); + }); +}); + +describe('isMessageTextSelectionEnabled', () => { + it('disables native text selection for chat messages', () => { + expect(isMessageTextSelectionEnabled()).toBe(false); + }); +}); + +describe('isMessageEdited', () => { + it('marks updated non-deleted messages as edited', () => { + expect(isMessageEdited(message({ clientUpdatedAt: 123 }))).toBe(true); + }); + + it('hides edited state for deleted messages', () => { + expect(isMessageEdited(message({ clientUpdatedAt: 123, deleted: true }))).toBe(false); + }); +}); + +describe('canShowReactionPills', () => { + it('hides reactions for deleted messages', () => { + expect( + canShowReactionPills( + message({ + deleted: true, + reactions: [{ emoji: '👍', count: 1, memberIds: ['user-1'] }], + }) + ) + ).toBe(false); + }); + + it('shows reactions for non-deleted messages with reactions', () => { + expect( + canShowReactionPills( + message({ + reactions: [{ emoji: '👍', count: 1, memberIds: ['user-1'] }], + }) + ) + ).toBe(true); + }); +}); + +describe('canToggleReaction', () => { + it('blocks reaction toggles for deleted messages', () => { + expect(canToggleReaction(message({ deleted: true }), 'user-1')).toBe(false); + }); + + it('blocks reaction toggles when the current user is not loaded', () => { + expect(canToggleReaction(message(), null)).toBe(false); + }); + + it('allows reaction toggles for loaded users on delivered messages', () => { + expect(canToggleReaction(message(), 'user-1')).toBe(true); + }); +}); + +describe('resolveMessageAuthorLabel', () => { + it('uses resolved display names for user senders', () => { + expect( + resolveMessageAuthorLabel({ + senderId: 'user-1', + members: [ + { id: 'user-1', kind: 'user', displayName: 'Igor Minar', avatarUrl: null }, + { id: 'bot:kiloclaw:sandbox-1', kind: 'bot', displayName: null, avatarUrl: null }, + ], + botName: 'Helper Bot', + }) + ).toBe('Igor Minar'); + }); + + it('uses the bot display name for bot senders', () => { + expect( + resolveMessageAuthorLabel({ + senderId: 'bot:kiloclaw:sandbox-1', + members: [ + { id: 'bot:kiloclaw:sandbox-1', kind: 'bot', displayName: null, avatarUrl: null }, + ], + botName: 'Helper Bot', + }) + ).toBe('Helper Bot'); + }); + + it('falls back to stable labels when resolved names are missing', () => { + expect(resolveMessageAuthorLabel({ senderId: 'bot:kiloclaw:sandbox-1' })).toBe('KiloClaw'); + expect(resolveMessageAuthorLabel({ senderId: 'user-1' })).toBe('user-1'); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/message-presentation.ts b/apps/mobile/src/components/kilo-chat/message-presentation.ts new file mode 100644 index 0000000000..daec7d7167 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-presentation.ts @@ -0,0 +1,116 @@ +import { + type ConversationDetailResponse, + type CreateMessageRequest, + type Message, + type ReplyToMessageSnapshot, +} from '@kilocode/kilo-chat'; +import * as Crypto from 'expo-crypto'; +import { ulid } from 'ulid'; + +type SendMessageVariables = CreateMessageRequest & { clientId: string }; +export type ReplyPreviewSource = Message | ReplyToMessageSnapshot; +export type MessageAuthorMember = ConversationDetailResponse['members'][number]; + +type BuildSendMessageVariablesInput = { + conversationId: string; + text: string; + clientId: string; + inReplyToMessageId?: string; +}; + +export function buildSendMessageVariables({ + conversationId, + text, + clientId, + inReplyToMessageId, +}: BuildSendMessageVariablesInput): SendMessageVariables { + const content: CreateMessageRequest['content'] = [{ type: 'text', text }]; + return { + conversationId, + content, + clientId, + ...(inReplyToMessageId ? { inReplyToMessageId } : {}), + }; +} + +export function createSendMessageClientId(): string { + return ulid(undefined, expoCryptoPrng); +} + +function expoCryptoPrng(): number { + const bytes = Crypto.getRandomValues(new Uint8Array(1)); + const byte = bytes[0]; + if (byte === undefined) { + throw new Error('Failed to generate a random byte'); + } + return byte / 255; +} + +function contentBlocksToPreviewText(content: Message['content']): string { + const preview = content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('\n') + .trim(); + return preview || 'Message'; +} + +export function getReplyPreviewText(replyToMessage: ReplyPreviewSource): string { + if (replyToMessage.deleted) { + return '[deleted message]'; + } + if ('previewText' in replyToMessage) { + return replyToMessage.previewText ?? 'Message'; + } + return contentBlocksToPreviewText(replyToMessage.content); +} + +export function getDeliveryFailureLabel(message: Message): string | null { + return message.deliveryFailed ? 'Not delivered' : null; +} + +export function isMessageTextSelectionEnabled(): boolean { + return false; +} + +export function canShowReactionPills(message: Message): boolean { + return !message.deleted && message.reactions.length > 0; +} + +export function canToggleReaction(message: Message, currentUserId: string | null): boolean { + return currentUserId !== null && !message.deleted && !message.deliveryFailed; +} + +export function canCopyMessage(message: Message): boolean { + return !message.deleted && contentBlocksToPreviewText(message.content).trim().length > 0; +} + +export function isMessageEdited(message: Message): boolean { + return !message.deleted && message.clientUpdatedAt !== null; +} + +function firstDisplayValue(values: readonly (string | null | undefined)[]): string | null { + for (const value of values) { + const trimmed = value?.trim(); + if (trimmed) { + return trimmed; + } + } + return null; +} + +export function resolveMessageAuthorLabel({ + senderId, + members = [], + botName, +}: { + senderId: string; + members?: readonly MessageAuthorMember[]; + botName?: string | null; +}): string { + const member = members.find(candidate => candidate.id === senderId); + if (senderId.startsWith('bot:')) { + return firstDisplayValue([botName, member?.displayName]) ?? 'KiloClaw'; + } + return firstDisplayValue([member?.displayName]) ?? senderId; +} diff --git a/apps/mobile/src/components/kilo-chat/message-reaction-picker-sheet.tsx b/apps/mobile/src/components/kilo-chat/message-reaction-picker-sheet.tsx new file mode 100644 index 0000000000..a411e61076 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-reaction-picker-sheet.tsx @@ -0,0 +1,97 @@ +import { Portal } from '@rn-primitives/portal'; +import { X } from 'lucide-react-native'; +import { Pressable, View } from 'react-native'; + +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +const COMMON_REACTIONS = ['👍', '👎', '❤️', '😂', '🎉', '🚀', '👀', '✅', '🔥', '🙏', '💡', '🤔']; + +type MessageReactionPickerSheetProps = { + visible: boolean; + recentReactions: string[]; + onClose: () => void; + onSelect: (emoji: string) => void; +}; + +function uniqueReactions(reactions: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const reaction of reactions) { + if (!seen.has(reaction)) { + seen.add(reaction); + result.push(reaction); + } + } + return result; +} + +export function MessageReactionPickerSheet({ + visible, + recentReactions, + onClose, + onSelect, +}: Readonly) { + const colors = useThemeColors(); + if (!visible) { + return null; + } + + const recent = uniqueReactions(recentReactions).slice(0, 6); + + return ( + + + + + + Reactions + + + + + {recent.length > 0 ? ( + + ) : null} + + + + + ); +} + +function ReactionGrid({ + title, + reactions, + onSelect, +}: Readonly<{ + title: string; + reactions: string[]; + onSelect: (emoji: string) => void; +}>) { + return ( + + {title} + + {reactions.map(reaction => ( + { + onSelect(reaction); + }} + > + {reaction} + + ))} + + + ); +} diff --git a/apps/mobile/src/components/kilo-chat/message-reaction-pills.tsx b/apps/mobile/src/components/kilo-chat/message-reaction-pills.tsx new file mode 100644 index 0000000000..adee4596e7 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-reaction-pills.tsx @@ -0,0 +1,59 @@ +import { type Message } from '@kilocode/kilo-chat'; +import { Pressable, View } from 'react-native'; + +import { Text } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; +import { canShowReactionPills } from './message-presentation'; + +type MessageReactionPillsProps = { + message: Message; + currentUserId: string | null; + isFromMe: boolean; + onReactionPress: (message: Message, emoji: string) => void; +}; + +export function MessageReactionPills({ + message, + currentUserId, + isFromMe, + onReactionPress, +}: Readonly) { + if (!canShowReactionPills(message)) { + return null; + } + + return ( + + {message.reactions.map(reaction => { + const hasReacted = currentUserId ? reaction.memberIds.includes(currentUserId) : false; + return ( + { + onReactionPress(message, reaction.emoji); + }} + className={cn( + 'min-h-11 flex-row items-center gap-1 rounded-full px-3 py-1', + hasReacted ? 'bg-primary' : 'bg-neutral-200 dark:bg-neutral-700' + )} + > + {reaction.emoji} + + {reaction.count} + + + ); + })} + + ); +} diff --git a/apps/mobile/src/components/kilo-chat/reaction-operation-cache.test.ts b/apps/mobile/src/components/kilo-chat/reaction-operation-cache.test.ts new file mode 100644 index 0000000000..bd3f8b141c --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/reaction-operation-cache.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { type Message } from '@kilocode/kilo-chat'; + +import { + applyReactionAddedEventToPages, + applyReactionRemovedEventToPages, + createReactionOperationTracker, + type MessageInfiniteData, +} from '@kilocode/kilo-chat-hooks'; + +function message(id: string, reactions: Message['reactions'] = []): Message { + return { + id, + senderId: 'user:1', + content: [{ type: 'text', text: id }], + inReplyToMessageId: null, + replyTo: null, + updatedAt: null, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions, + }; +} + +function pages(cachedMessage: Message): MessageInfiniteData { + return { + pages: [{ messages: [cachedMessage], hasMore: false, nextCursor: null }], + pageParams: [undefined], + }; +} + +describe('reaction operation cache ordering', () => { + it('ignores an older reaction add after a newer remove for the same member', () => { + const tracker = createReactionOperationTracker(); + const data = pages(message('m1', [{ emoji: '👍', count: 1, memberIds: ['user-1'] }])); + + const afterRemove = applyReactionRemovedEventToPages(data, 'conversation-1', tracker, { + messageId: 'm1', + operationId: '01HX0000000000000000000002', + emoji: '👍', + memberId: 'user-1', + }); + const afterStaleAdd = applyReactionAddedEventToPages(afterRemove, 'conversation-1', tracker, { + messageId: 'm1', + operationId: '01HX0000000000000000000001', + emoji: '👍', + memberId: 'user-1', + }); + + expect(afterStaleAdd.pages[0]?.messages[0]?.reactions).toEqual([]); + }); + + it('applies a newer reaction add after an older remove for the same member', () => { + const tracker = createReactionOperationTracker(); + const data = pages(message('m1')); + + const afterRemove = applyReactionRemovedEventToPages(data, 'conversation-1', tracker, { + messageId: 'm1', + operationId: '01HX0000000000000000000001', + emoji: '👍', + memberId: 'user-1', + }); + const afterAdd = applyReactionAddedEventToPages(afterRemove, 'conversation-1', tracker, { + messageId: 'm1', + operationId: '01HX0000000000000000000002', + emoji: '👍', + memberId: 'user-1', + }); + + expect(afterAdd.pages[0]?.messages[0]?.reactions).toEqual([ + { emoji: '👍', count: 1, memberIds: ['user-1'] }, + ]); + }); + + it("does not suppress another member's reaction with a newer operation", () => { + const tracker = createReactionOperationTracker(); + const data = pages(message('m1')); + + const afterUserOne = applyReactionAddedEventToPages(data, 'conversation-1', tracker, { + messageId: 'm1', + operationId: '01HX0000000000000000000002', + emoji: '👍', + memberId: 'user-1', + }); + const afterUserTwo = applyReactionAddedEventToPages(afterUserOne, 'conversation-1', tracker, { + messageId: 'm1', + operationId: '01HX0000000000000000000001', + emoji: '👍', + memberId: 'user-2', + }); + + expect(afterUserTwo.pages[0]?.messages[0]?.reactions).toEqual([ + { emoji: '👍', count: 2, memberIds: ['user-1', 'user-2'] }, + ]); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/rename-conversation-sheet.tsx b/apps/mobile/src/components/kilo-chat/rename-conversation-sheet.tsx new file mode 100644 index 0000000000..6f33437c26 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/rename-conversation-sheet.tsx @@ -0,0 +1,87 @@ +import { CONVERSATION_TITLE_MAX_CHARS } from '@kilocode/kilo-chat'; +import { useRef, useState } from 'react'; +import { Pressable, TextInput, View } from 'react-native'; + +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +type RenameConversationSheetProps = { + initialTitle: string; + isSaving: boolean; + onCancel: () => void; + onSave: (title: string) => void; +}; + +function canSaveTitle(text: string, initialTitle: string): boolean { + const trimmed = text.trim(); + return ( + trimmed.length > 0 && + trimmed.length <= CONVERSATION_TITLE_MAX_CHARS && + trimmed !== initialTitle.trim() + ); +} + +export function RenameConversationSheet({ + initialTitle, + isSaving, + onCancel, + onSave, +}: Readonly) { + const colors = useThemeColors(); + const titleRef = useRef(initialTitle); + const [canSave, setCanSave] = useState(false); + + function handleTextChange(text: string) { + titleRef.current = text; + setCanSave(canSaveTitle(text, initialTitle)); + } + + function handleSave() { + const title = titleRef.current.trim(); + if (canSaveTitle(title, initialTitle)) { + onSave(title); + } + } + + return ( + + + + Rename conversation + Set a short name for this thread. + + + + + Cancel + + + + + + ); +} diff --git a/apps/mobile/src/components/kilo-chat/typing-indicator-text.ts b/apps/mobile/src/components/kilo-chat/typing-indicator-text.ts new file mode 100644 index 0000000000..6d5793205a --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/typing-indicator-text.ts @@ -0,0 +1,16 @@ +export function formatTypingIndicatorText({ + botName, + typingMemberIds, +}: { + botName?: string | null; + typingMemberIds: readonly string[]; +}): string | null { + if (typingMemberIds.length === 0) { + return null; + } + + const names = typingMemberIds.map(memberId => + memberId.startsWith('bot:') ? (botName ?? 'KiloClaw') : 'Someone' + ); + return names.length === 1 ? `${names[0]} is typing...` : `${names.join(', ')} are typing...`; +} diff --git a/apps/mobile/src/components/kilo-chat/typing-indicator.test.ts b/apps/mobile/src/components/kilo-chat/typing-indicator.test.ts new file mode 100644 index 0000000000..d72e6ad4b0 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/typing-indicator.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { formatTypingIndicatorText } from './typing-indicator-text'; + +describe('typing indicator text', () => { + it('uses the active bot name for bot typing events', () => { + expect( + formatTypingIndicatorText({ + botName: 'Production Bot', + typingMemberIds: ['bot:sandbox-1'], + }) + ).toBe('Production Bot is typing...'); + }); + + it('returns null when nobody is typing', () => { + expect( + formatTypingIndicatorText({ botName: 'Production Bot', typingMemberIds: [] }) + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/typing-indicator.tsx b/apps/mobile/src/components/kilo-chat/typing-indicator.tsx new file mode 100644 index 0000000000..fd588b2f2b --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/typing-indicator.tsx @@ -0,0 +1,21 @@ +import { View } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { formatTypingIndicatorText } from './typing-indicator-text'; + +type Props = { + botName?: string | null; + typingMembers: Map; +}; + +export function TypingIndicator({ botName, typingMembers }: Props) { + const text = formatTypingIndicatorText({ + botName, + typingMemberIds: [...typingMembers.keys()], + }); + + return ( + + {text ? {text} : null} + + ); +} diff --git a/apps/mobile/src/components/kiloclaw/chat-avatar.tsx b/apps/mobile/src/components/kiloclaw/chat-avatar.tsx deleted file mode 100644 index 9445553291..0000000000 --- a/apps/mobile/src/components/kiloclaw/chat-avatar.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { View } from 'react-native'; -import { type MessageAvatarProps, useMessageContext } from 'stream-chat-expo'; - -import logo from '@/../assets/images/logo.png'; -import { Image } from '@/components/ui/image'; - -export function KiloClawMessageAvatar(_props: MessageAvatarProps) { - const { message, lastGroupMessage } = useMessageContext(); - // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- message can be undefined at runtime in reply swipe context - const isBotMessage = message?.user?.id?.startsWith('bot-'); - - if (!lastGroupMessage) { - return ; - } - - if (isBotMessage) { - return ( - - - - ); - } - - return ; -} diff --git a/apps/mobile/src/components/kiloclaw/chat-hooks.ts b/apps/mobile/src/components/kiloclaw/chat-hooks.ts deleted file mode 100644 index 7f32cf37bc..0000000000 --- a/apps/mobile/src/components/kiloclaw/chat-hooks.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useEffect, useState } from 'react'; -import { type Event, type Channel as StreamChannel, type StreamChat } from 'stream-chat'; - -export function useBotOnlineStatus( - client: StreamChat | null, - channel: StreamChannel | null, - botUserId: string -): boolean { - const [online, setOnline] = useState(false); - - useEffect(() => { - const handlePresenceChange = (event: Event) => { - if (event.user?.id === botUserId) { - setOnline(Boolean(event.user.online)); - } - }; - - if (client && channel) { - // Check initial state - const member = channel.state.members[botUserId]; - setOnline(Boolean(member?.user?.online)); - client.on('user.presence.changed', handlePresenceChange); - } - - return () => { - client?.off('user.presence.changed', handlePresenceChange); - }; - }, [client, channel, botUserId]); - - return online; -} diff --git a/apps/mobile/src/components/kiloclaw/chat-placeholder.tsx b/apps/mobile/src/components/kiloclaw/chat-placeholder.tsx deleted file mode 100644 index 35017111fe..0000000000 --- a/apps/mobile/src/components/kiloclaw/chat-placeholder.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { View } from 'react-native'; - -import { Text } from '@/components/ui/text'; - -export function ChatPlaceholder({ message }: { message: string }) { - return ( - - {message} - - ); -} diff --git a/apps/mobile/src/components/kiloclaw/chat-shell.tsx b/apps/mobile/src/components/kiloclaw/chat-shell.tsx deleted file mode 100644 index 6bc1ebf35a..0000000000 --- a/apps/mobile/src/components/kiloclaw/chat-shell.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { type Href, useRouter } from 'expo-router'; -import { Settings } from 'lucide-react-native'; -import { Pressable, View } from 'react-native'; - -import { ScreenHeader } from '@/components/screen-header'; -import { Text } from '@/components/ui/text'; -import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; - -function BotStatusIndicator({ online }: { online: boolean }) { - return ( - - - {online ? 'Online' : 'Offline'} - - ); -} - -export function ChatHeader({ - instanceId, - title, - botOnline, -}: { - instanceId: string; - title: string; - botOnline?: boolean; -}) { - const router = useRouter(); - const colors = useThemeColors(); - const { data: instances } = useAllKiloClawInstances(); - - const hasMultipleInstances = (instances?.length ?? 0) > 1; - - const handleTitlePress = () => { - const href: Href = { - pathname: '/(app)/chat/instance-picker', - params: { currentId: instanceId }, - }; - router.push(href); - }; - - const settingsButton = ( - { - router.push(`/(app)/kiloclaw/${instanceId}/dashboard` as Href); - }} - hitSlop={12} - accessibilityLabel="Settings" - className="active:opacity-70" - > - - - ); - - return ( - - {botOnline !== undefined && } - {settingsButton} - - } - /> - ); -} - -export function ChatShell({ - instanceId, - name, - children, -}: { - instanceId: string; - name: string; - children: React.ReactNode; -}) { - return ( - - - {children} - - ); -} diff --git a/apps/mobile/src/components/kiloclaw/chat-theme.ts b/apps/mobile/src/components/kiloclaw/chat-theme.ts deleted file mode 100644 index 2472ec5cbb..0000000000 --- a/apps/mobile/src/components/kiloclaw/chat-theme.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useColorScheme } from 'react-native'; -import { type DeepPartial, type Theme } from 'stream-chat-expo'; - -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; - -export function useStreamChatTheme(): DeepPartial { - const colorScheme = useColorScheme(); - const colors = useThemeColors(); - - const [theme, setTheme] = useState>(() => buildTheme(colorScheme, colors)); - - useEffect(() => { - setTheme(buildTheme(colorScheme, colors)); - }, [colorScheme, colors]); - - return theme; -} - -function buildTheme( - colorScheme: ReturnType, - colors: ReturnType -): DeepPartial { - return { - colors: - colorScheme === 'dark' - ? { - black: colors.foreground, - white: colors.background, - white_smoke: colors.secondary, - white_snow: colors.muted, - grey: colors.mutedForeground, - grey_dark: colors.mutedForeground, - grey_gainsboro: colors.border, - grey_whisper: colors.border, - light_blue: 'hsl(0, 0%, 20%)', - light_gray: 'hsl(0, 0%, 20%)', - blue_alice: 'hsl(0, 0%, 18%)', - text_high_emphasis: colors.foreground, - text_low_emphasis: colors.mutedForeground, - bg_gradient_start: colors.background, - bg_gradient_end: colors.secondary, - icon_background: colors.card, - overlay: 'rgba(0, 0, 0, 0.8)', - } - : {}, - dateHeader: { - container: { - backgroundColor: colorScheme === 'dark' ? 'rgba(255, 255, 255, 0.12)' : undefined, - }, - text: { - color: colorScheme === 'dark' ? colors.foreground : undefined, - }, - }, - inlineDateSeparator: { - container: { - backgroundColor: colorScheme === 'dark' ? 'rgba(255, 255, 255, 0.12)' : undefined, - }, - text: { - color: colorScheme === 'dark' ? colors.foreground : undefined, - }, - }, - messageInput: { - container: { - paddingHorizontal: 12, - borderColor: colors.border, - }, - }, - }; -} diff --git a/apps/mobile/src/components/kiloclaw/chat.tsx b/apps/mobile/src/components/kiloclaw/chat.tsx deleted file mode 100644 index 626cde8230..0000000000 --- a/apps/mobile/src/components/kiloclaw/chat.tsx +++ /dev/null @@ -1,296 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { ActivityIndicator, View } from 'react-native'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useFocusEffect } from 'expo-router'; -import { Image as ExpoImage } from 'expo-image'; // eslint-disable-line no-restricted-imports -- raw expo-image needed for Stream Chat SDK ImageComponent prop -import * as Notifications from 'expo-notifications'; -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 { KiloClawMessageAvatar } from '@/components/kiloclaw/chat-avatar'; -import { ChatPlaceholder } from '@/components/kiloclaw/chat-placeholder'; -import { ChatHeader, ChatShell } from '@/components/kiloclaw/chat-shell'; -import { useBotOnlineStatus } from '@/components/kiloclaw/chat-hooks'; -import { NotificationPrompt } from '@/components/kiloclaw/notification-prompt'; -import { useStreamChatTheme } from '@/components/kiloclaw/chat-theme'; -import { useAppLifecycle } from '@/lib/hooks/use-app-lifecycle'; -import { useStreamChatCredentials } from '@/lib/hooks/use-kiloclaw-queries'; -import { setLastActiveInstance } from '@/lib/last-active-instance'; -import { parseNotificationData, setActiveChatInstance } from '@/lib/notifications'; -import { useTRPC } from '@/lib/trpc'; - -type KiloClawChatProps = { - instanceId: string; - name: string; - enabled: boolean; - organizationId?: string | null; -}; - -type UnreadCountsData = { channelId: string; badgeCount: number }[]; - -export function KiloClawChat({ - instanceId, - name, - enabled, - organizationId, -}: Readonly) { - const { data: creds, isLoading, error } = useStreamChatCredentials(organizationId, enabled); - const trpc = useTRPC(); - const { isActive } = useAppLifecycle(); - const isFocusedRef = useRef(false); - - const queryClient = useQueryClient(); - const unreadCountsKey = useMemo(() => trpc.user.getUnreadCounts.queryOptions().queryKey, [trpc]); - - const { mutate: markChatRead } = useMutation( - trpc.user.markChatRead.mutationOptions({ - onMutate: async ({ channelId }) => { - await queryClient.cancelQueries({ queryKey: unreadCountsKey }); - const previous = queryClient.getQueryData(unreadCountsKey); - queryClient.setQueryData(unreadCountsKey, old => - (old ?? []).filter(row => row.channelId !== channelId) - ); - return { previous }; - }, - onSuccess: ({ badgeCount }) => { - void Notifications.setBadgeCountAsync(badgeCount); - }, - onError: (err: { message: string }, _input, context) => { - if (context?.previous) { - queryClient.setQueryData(unreadCountsKey, context.previous); - } - toast.error(err.message || 'Failed to update badge count'); - }, - onSettled: () => { - void queryClient.invalidateQueries({ queryKey: unreadCountsKey }); - }, - }) - ); - - useFocusEffect( - useCallback(() => { - isFocusedRef.current = true; - setActiveChatInstance(instanceId); - setLastActiveInstance(instanceId); - markChatRead({ channelId: instanceId }); - - // If a notification for this chat arrives while the screen is already open it is - // visually suppressed, but the DO still incremented the server-side count. Clear - // 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?.type === 'chat' && data.instanceId === instanceId) { - markChatRead({ channelId: instanceId }); - } - }); - - return () => { - isFocusedRef.current = false; - setActiveChatInstance(null); - subscription.remove(); - }; - }, [instanceId, markChatRead]) - ); - - // Clear badge when the app returns to the foreground while this chat is focused. - // Notifications received in the background do not fire the listener above, and - // useFocusEffect does not re-run on app resume (focus is a navigation concept, - // not an app-state one), so without this the badge stays stuck after backgrounding. - useEffect(() => { - if (isActive && isFocusedRef.current) { - markChatRead({ channelId: instanceId }); - } - }, [isActive, instanceId, markChatRead]); - - if (!enabled) { - return ( - - - - ); - } - - if (isLoading) { - return ( - - - - - - ); - } - - if (error) { - return ( - - - - ); - } - - if (!creds) { - return ( - - - - ); - } - - return ( - - ); -} - -function StreamChatUI({ - instanceId, - name, - apiKey, - userId, - channelId, - organizationId, -}: { - instanceId: string; - name: string; - apiKey: string; - userId: string; - channelId: string; - organizationId?: string | null; -}) { - const { bottom } = useSafeAreaInsets(); - const [headerHeight, setHeaderHeight] = useState(0); - const chatTheme = useStreamChatTheme(); - const trpc = useTRPC(); - const queryClient = useQueryClient(); - - const tokenProvider = useCallback(async () => { - const opts = organizationId - ? trpc.organizations.kiloclaw.getStreamChatCredentials.queryOptions( - { organizationId }, - { staleTime: 0 } - ) - : trpc.kiloclaw.getStreamChatCredentials.queryOptions(undefined, { staleTime: 0 }); - const creds = await queryClient.fetchQuery(opts); - if (!creds?.userToken) { - throw new Error('Failed to fetch Stream Chat credentials'); - } - return creds.userToken; - }, [queryClient, trpc, organizationId]); - - const [client, setClient] = useState(null); - const [channel, setChannel] = useState(null); - const [connectError, setConnectError] = useState(null); - - useEffect(() => { - const chatClient = StreamChat.getInstance(apiKey); - - let cancelled = false; - setConnectError(null); - - const connect = async () => { - try { - // Await disconnect to prevent tokenManager.reset() from racing with the new connection - if (chatClient.userID) { - await chatClient.disconnectUser(); - } - if (cancelled) { - return; - } - await chatClient.connectUser({ id: userId }, tokenProvider); - const ch = chatClient.channel('messaging', channelId); - await ch.watch({ presence: true }); - // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- cancelled can change across awaits - if (!cancelled) { - setClient(chatClient); - setChannel(ch); - } - } catch (error) { - if (!cancelled) { - setConnectError(error instanceof Error ? error.message : 'Failed to connect to chat.'); - } - } - }; - - void connect(); - - return () => { - cancelled = true; - setClient(null); - setChannel(null); - }; - }, [apiKey, userId, channelId, tokenProvider]); - - // Gracefully close/reopen the websocket on background/foreground. - // This preserves the client and channel state (no disconnect/reconnect). - const { isActive } = useAppLifecycle(); - const wasActiveRef = useRef(isActive); - useEffect(() => { - if (client) { - if (wasActiveRef.current && !isActive) { - void client.closeConnection(); - } else if (!wasActiveRef.current && isActive) { - void client.openConnection(); - } - } - wasActiveRef.current = isActive; - }, [client, isActive]); - - // Bot presence tracking - const sandboxId = channelId.replace(/^default-/, ''); - const botUserId = `bot-${sandboxId}`; - const botOnline = useBotOnlineStatus(client, channel, botUserId); - - if (connectError) { - return ( - - - - ); - } - - if (!client || !channel) { - return ( - - - - - - ); - } - - return ( - - { - setHeaderHeight(e.nativeEvent.layout.height); - }} - > - - - - - {/* eslint-disable-next-line typescript-eslint/no-unsafe-assignment -- expo-image is API-compatible with RN Image */} - - - - - - - - - - - ); -} diff --git a/apps/mobile/src/components/kiloclaw/instance-card.tsx b/apps/mobile/src/components/kiloclaw/instance-card.tsx new file mode 100644 index 0000000000..2cc02ca4d5 --- /dev/null +++ b/apps/mobile/src/components/kiloclaw/instance-card.tsx @@ -0,0 +1,148 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'expo-router'; +import { Settings2 } from 'lucide-react-native'; +import { Pressable, View } from 'react-native'; + +import { isTransitionalStatus, statusLabel, statusTone } from '@/components/kiloclaw/status-badge'; +import { StatusDot } from '@/components/ui/status-dot'; +import { Text } from '@/components/ui/text'; +import { agentColor } from '@/lib/agent-color'; +import { useKiloClawStatus, useKiloClawStatusQueryKey } from '@/lib/hooks/use-kiloclaw-queries'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { chatSandboxPath } from '@/lib/kilo-chat-routes'; + +type KiloClawCardProps = { + instance: { + sandboxId: string; + name: string | null; + botName?: string | null; + botEmoji?: string | null; + organizationId: string | null; + organizationName: string | null; + status: string | null; + }; + unreadCount?: number; + onPress?: (sandboxId: string) => void; + onSettingsPress?: (sandboxId: string) => void; +}; + +type CachedStatus = NonNullable['data']>; + +function formatUnreadCount(count: number): string { + return count > 99 ? '99+' : String(count); +} + +function firstLetter(name: string): string { + const trimmed = name.trim(); + return trimmed.length > 0 ? (trimmed[0]?.toUpperCase() ?? 'K') : 'K'; +} + +export function KiloClawCard({ + instance, + unreadCount = 0, + onPress, + onSettingsPress, +}: Readonly) { + const router = useRouter(); + const colors = useThemeColors(); + + // Peek at the latest cached status (non-subscribing) so we can choose the + // poll cadence before subscribing. Falls back to the list's status when + // the status cache is cold. When the live query refreshes below, + // re-render recomputes this and the interval flips. + const queryClient = useQueryClient(); + const statusQueryKey = useKiloClawStatusQueryKey(instance.organizationId); + const cachedStatus = queryClient.getQueryData(statusQueryKey); + const effectiveStatus = cachedStatus?.status ?? instance.status ?? null; + const fastPoll = isTransitionalStatus(effectiveStatus); + + const { data: status } = useKiloClawStatus( + instance.organizationId, + true, + fastPoll ? 5000 : 10_000 + ); + + const botEmoji = status?.botEmoji ?? instance.botEmoji ?? null; + const displayName = status?.botName ?? instance.botName ?? instance.name ?? 'KiloClaw'; + const rawStatus = status?.status ?? instance.status ?? 'offline'; + const tone = statusTone(rawStatus); + const label = statusLabel(rawStatus); + const tapDisabled = isTransitionalStatus(rawStatus); + + const hue = agentColor(displayName); + + const hasUnread = unreadCount > 0; + const accessibilityLabel = hasUnread + ? `Open ${displayName}, ${unreadCount} unread ${unreadCount === 1 ? 'message' : 'messages'}` + : `Open ${displayName}`; + + const handlePress = () => { + if (onPress) { + onPress(instance.sandboxId); + return; + } + router.push(chatSandboxPath(instance.sandboxId)); + }; + + const handleSettingsPress = () => { + onSettingsPress?.(instance.sandboxId); + }; + + return ( + + + + + + {botEmoji ? ( + {botEmoji} + ) : ( + + {firstLetter(displayName)} + + )} + + + + + {displayName} + + + + + {label} + + + + {hasUnread ? ( + + + {formatUnreadCount(unreadCount)} + + + ) : null} + {onSettingsPress ? ( + + + + ) : null} + + + ); +} diff --git a/apps/mobile/src/components/kiloclaw/instance-entry-state.test.ts b/apps/mobile/src/components/kiloclaw/instance-entry-state.test.ts new file mode 100644 index 0000000000..9f58e2eea9 --- /dev/null +++ b/apps/mobile/src/components/kiloclaw/instance-entry-state.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { getKiloClawEntryDecision } from './instance-entry-state'; + +const personal = { sandboxId: 'personal-1' }; +const org = { sandboxId: 'org-1' }; + +describe('getKiloClawEntryDecision', () => { + it('waits while instances are unresolved', () => { + expect(getKiloClawEntryDecision(undefined)).toEqual({ kind: 'loading' }); + }); + + it('shows onboarding when there are no instances', () => { + expect(getKiloClawEntryDecision([])).toEqual({ kind: 'empty' }); + }); + + it('shows the picker when exactly one instance exists', () => { + expect(getKiloClawEntryDecision([personal])).toEqual({ kind: 'list' }); + }); + + it('shows the picker when multiple instances exist', () => { + expect(getKiloClawEntryDecision([personal, org])).toEqual({ kind: 'list' }); + }); +}); diff --git a/apps/mobile/src/components/kiloclaw/instance-entry-state.ts b/apps/mobile/src/components/kiloclaw/instance-entry-state.ts new file mode 100644 index 0000000000..40719e17ee --- /dev/null +++ b/apps/mobile/src/components/kiloclaw/instance-entry-state.ts @@ -0,0 +1,17 @@ +type InstanceLike = { + sandboxId: string; +}; + +type KiloClawEntryDecision = { kind: 'loading' } | { kind: 'empty' } | { kind: 'list' }; + +export function getKiloClawEntryDecision( + instances: readonly InstanceLike[] | undefined +): KiloClawEntryDecision { + if (instances === undefined) { + return { kind: 'loading' }; + } + if (instances.length === 0) { + return { kind: 'empty' }; + } + return { kind: 'list' }; +} diff --git a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx new file mode 100644 index 0000000000..37509f680f --- /dev/null +++ b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx @@ -0,0 +1,154 @@ +import * as Haptics from 'expo-haptics'; +import { Plus } from 'lucide-react-native'; +import { RefreshControl, ScrollView, View } from 'react-native'; +import Animated, { FadeIn } from 'react-native-reanimated'; + +import { badgeBucketForInstance } from '@kilocode/notifications'; + +import { KiloClawCard } from '@/components/kiloclaw/instance-card'; +import { ProfileAvatarButton } from '@/components/profile-avatar-button'; +import { ScreenHeader } from '@/components/screen-header'; +import { Button } from '@/components/ui/button'; +import { Eyebrow } from '@/components/ui/eyebrow'; +import { Text } from '@/components/ui/text'; +import { type ClawInstance } from '@/lib/hooks/use-instance-context'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +type Props = { + instances: ClawInstance[]; + onSelect: (sandboxId: string) => void; + onSettingsPress: (sandboxId: string) => void; + onCreate: () => void; + refreshing: boolean; + onRefresh: () => void; + unreadByBadgeBucket?: Map; + showSectionCounts?: boolean; +}; + +function splitInstances(instances: ClawInstance[]) { + return { + personal: instances.filter(instance => instance.organizationId === null), + organizations: instances.filter(instance => instance.organizationId !== null), + }; +} + +function InstanceSection({ + title, + instances, + onSelect, + onSettingsPress, + unreadByBadgeBucket, + showCount, +}: Readonly<{ + title: string; + instances: ClawInstance[]; + onSelect: (sandboxId: string) => void; + onSettingsPress: (sandboxId: string) => void; + unreadByBadgeBucket?: Map; + showCount: boolean; +}>) { + if (instances.length === 0) { + return null; + } + + return ( + + + {title} + {showCount ? ( + + {instances.length} + + ) : null} + + + {instances.map(instance => ( + + ))} + + + ); +} + +export function InstanceListScreen({ + instances, + onSelect, + onSettingsPress, + onCreate, + refreshing, + onRefresh, + unreadByBadgeBucket, + showSectionCounts = false, +}: Readonly) { + const colors = useThemeColors(); + const { personal, organizations } = splitInstances(instances); + + function handleSelect(sandboxId: string) { + void Haptics.selectionAsync(); + onSelect(sandboxId); + } + + function handleSettingsPress(sandboxId: string) { + void Haptics.selectionAsync(); + onSettingsPress(sandboxId); + } + + return ( + + } + /> + + } + > + {instances.length === 0 ? ( + + + + ) : null} + + + + + + + ); +} diff --git a/apps/mobile/src/components/kiloclaw/notification-prompt.tsx b/apps/mobile/src/components/kiloclaw/notification-prompt.tsx deleted file mode 100644 index 0348ab22c9..0000000000 --- a/apps/mobile/src/components/kiloclaw/notification-prompt.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { Bell } from 'lucide-react-native'; -import { useCallback, useEffect, useState } from 'react'; -import { Alert, Linking, View } from 'react-native'; -import * as Notifications from 'expo-notifications'; -import * as SecureStore from 'expo-secure-store'; -import { useMutation } from '@tanstack/react-query'; -import { toast } from 'sonner-native'; -import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; - -import { Button } from '@/components/ui/button'; -import { Text } from '@/components/ui/text'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { - getNotificationPermissionStatus, - getPlatform, - registerForPushNotifications, -} from '@/lib/notifications'; -import { NOTIFICATION_PROMPT_SEEN_KEY } from '@/lib/storage-keys'; -import { useTRPC } from '@/lib/trpc'; - -export function NotificationPrompt({ enabled }: { enabled: boolean }) { - const [visible, setVisible] = useState(false); - const colors = useThemeColors(); - const trpc = useTRPC(); - - const registerToken = useMutation( - trpc.user.registerPushToken.mutationOptions({ - onError: error => { - toast.error(error.message); - }, - }) - ); - - useEffect(() => { - if (!enabled) { - return; - } - - async function check() { - const seen = await SecureStore.getItemAsync(NOTIFICATION_PROMPT_SEEN_KEY); - if (seen) { - return; - } - - const status = await getNotificationPermissionStatus(); - if (status === 'granted') { - return; - } - - setVisible(true); - } - void check(); - }, [enabled]); - - 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 result = await Notifications.requestPermissionsAsync(); - if (result.status !== Notifications.PermissionStatus.GRANTED) { - return; - } - - await SecureStore.setItemAsync(NOTIFICATION_PROMPT_SEEN_KEY, 'true'); - setVisible(false); - - const token = await registerForPushNotifications(); - if (token) { - registerToken.mutate( - { token, platform: getPlatform() }, - { - onSuccess: () => { - toast.success('Notifications enabled'); - }, - } - ); - } - }, [registerToken]); - - const handleDismiss = useCallback(async () => { - await SecureStore.setItemAsync(NOTIFICATION_PROMPT_SEEN_KEY, 'true'); - setVisible(false); - }, []); - - if (!visible) { - return null; - } - - return ( - - - - - Get notified when Kilo replies - - We'll send a push notification so you don't miss anything. - - - - - - - - - ); -} diff --git a/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx b/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx index 43916918e8..94bceb0aa8 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx @@ -43,6 +43,7 @@ import { useKiloClawStatus, } from '@/lib/hooks/use-kiloclaw-queries'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { chatSandboxPath } from '@/lib/kilo-chat-routes'; import { useTRPC } from '@/lib/trpc'; function categorizeProvisionError(error: { @@ -309,12 +310,12 @@ export function OnboardingFlow() { ]); const onOpenInstance = useCallback(() => { - // Dismiss the onboarding modal, then open the chat. `chat/[instance-id]` + // Dismiss the onboarding modal, then open the chat. `chat/[sandbox-id]` // is at the (app) layer, so it renders above the tab bar once the modal // closes. router.back(); if (state.sandboxId) { - router.push(`/(app)/chat/${state.sandboxId}` as Href); + router.push(chatSandboxPath(state.sandboxId)); } }, [router, state.sandboxId]); diff --git a/apps/mobile/src/components/kiloclaw/status-badge.tsx b/apps/mobile/src/components/kiloclaw/status-badge.tsx index b94a2c2f96..136acb4d0d 100644 --- a/apps/mobile/src/components/kiloclaw/status-badge.tsx +++ b/apps/mobile/src/components/kiloclaw/status-badge.tsx @@ -55,7 +55,7 @@ export function statusLabel(status: StatusValue | string): string { export function StatusBadge({ status, className, -}: Readonly<{ status: StatusValue; className?: string }>) { +}: Readonly<{ status: StatusValue | string; className?: string }>) { const tone = statusTone(status); const label = statusLabel(status); diff --git a/apps/mobile/src/lib/badge-freshness.ts b/apps/mobile/src/lib/badge-freshness.ts new file mode 100644 index 0000000000..66327bd29b --- /dev/null +++ b/apps/mobile/src/lib/badge-freshness.ts @@ -0,0 +1,10 @@ +let badgeFreshnessEpoch = 0; + +export function readBadgeFreshnessEpoch(): number { + return badgeFreshnessEpoch; +} + +export function advanceBadgeFreshnessEpoch(): number { + badgeFreshnessEpoch += 1; + return badgeFreshnessEpoch; +} diff --git a/apps/mobile/src/lib/badge-hydration.ts b/apps/mobile/src/lib/badge-hydration.ts new file mode 100644 index 0000000000..916712c227 --- /dev/null +++ b/apps/mobile/src/lib/badge-hydration.ts @@ -0,0 +1,26 @@ +import { type BadgeCountRow } from '@kilocode/notifications'; + +type ReconcileHydratedBadgeCountInput = { + badgeRows: BadgeCountRow[]; + startBadgeFreshnessEpoch: number; + currentBadgeFreshnessEpoch: number; + setBadgeCount: (badgeCount: number) => Promise; +}; + +export function totalBadgeCount(badgeRows: BadgeCountRow[]): number { + return badgeRows.reduce((total, row) => total + row.badgeCount, 0); +} + +export function reconcileHydratedBadgeCount({ + badgeRows, + startBadgeFreshnessEpoch, + currentBadgeFreshnessEpoch, + setBadgeCount, +}: ReconcileHydratedBadgeCountInput): boolean { + if (currentBadgeFreshnessEpoch !== startBadgeFreshnessEpoch) { + return false; + } + + void setBadgeCount(totalBadgeCount(badgeRows)); + return true; +} diff --git a/apps/mobile/src/lib/config.ts b/apps/mobile/src/lib/config.ts index b90c113990..90f83306a7 100644 --- a/apps/mobile/src/lib/config.ts +++ b/apps/mobile/src/lib/config.ts @@ -18,3 +18,7 @@ export const APPSFLYER_APP_ID: string = required('appsFlyerAppId'); export const CLOUD_AGENT_WS_URL: string = required('cloudAgentWsUrl'); export const SESSION_INGEST_WS_URL: string = required('sessionIngestWsUrl'); + +export const KILO_CHAT_URL: string = required('kiloChatUrl'); +export const EVENT_SERVICE_URL: string = required('eventServiceUrl'); +export const NOTIFICATIONS_URL: string = required('notificationsUrl'); diff --git a/apps/mobile/src/lib/env-keys.js b/apps/mobile/src/lib/env-keys.js index 3ec4d72001..0cb0bca167 100644 --- a/apps/mobile/src/lib/env-keys.js +++ b/apps/mobile/src/lib/env-keys.js @@ -7,4 +7,7 @@ export const ENV_KEYS = { sessionIngestWsUrl: 'SESSION_INGEST_WS_URL', appsFlyerDevKey: 'APPSFLYER_DEV_KEY', appsFlyerAppId: 'APPSFLYER_APP_ID', + kiloChatUrl: 'KILO_CHAT_URL', + eventServiceUrl: 'EVENT_SERVICE_URL', + notificationsUrl: 'NOTIFICATIONS_URL', }; diff --git a/apps/mobile/src/lib/hooks/use-kiloclaw-latest-message.ts b/apps/mobile/src/lib/hooks/use-kiloclaw-latest-message.ts deleted file mode 100644 index 64b1b366a2..0000000000 --- a/apps/mobile/src/lib/hooks/use-kiloclaw-latest-message.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; - -import { useStreamChatCredentials } from '@/lib/hooks/use-kiloclaw-queries'; - -const STREAM_CHAT_API_BASE = 'https://chat.stream-io-api.com'; - -type LatestMessage = { - text: string; - isFromMe: boolean; - created_at: string; -}; - -type StreamChatCredentials = { - apiKey: string; - userId: string; - userToken: string; - channelId: string; -}; - -type ChannelQueryResponse = { - messages?: { - text?: string; - created_at?: string; - user?: { id?: string }; - }[]; -}; - -async function fetchLatestMessage(creds: StreamChatCredentials): Promise { - const res = await fetch( - `${STREAM_CHAT_API_BASE}/channels/messaging/${creds.channelId}/query?api_key=${creds.apiKey}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Stream-Auth-Type': 'jwt', - Authorization: creds.userToken, - }, - body: JSON.stringify({ - state: true, - messages: { limit: 1 }, - }), - } - ); - - if (!res.ok) { - if (res.status === 404) { - return null; - } - const body = await res.text().catch(() => '(unreadable)'); - throw new Error(`Stream Chat query failed (${res.status}): ${body}`); - } - - const payload = (await res.json()) as ChannelQueryResponse; - const message = payload.messages?.[0]; - if (!message?.created_at) { - return null; - } - - return { - text: message.text ?? '', - isFromMe: message.user?.id === creds.userId, - created_at: message.created_at, - }; -} - -/** - * Fetch the most recent message on the KiloClaw chat channel directly from - * Stream Chat, reusing the short-lived user credentials exposed by - * `useStreamChatCredentials`. No extra backend endpoint required. - */ -export function useKiloClawLatestMessage(organizationId?: string | null, enabled = true) { - const { data: creds } = useStreamChatCredentials(organizationId, enabled); - const queryEnabled = enabled && Boolean(creds); - return useQuery({ - queryKey: ['kiloclaw-latest-message', creds?.channelId ?? null], - queryFn: async () => { - if (!creds) { - return null; - } - const latest = await fetchLatestMessage(creds); - return latest; - }, - enabled: queryEnabled, - staleTime: 30_000, - refetchInterval: queryEnabled ? 60_000 : false, - }); -} diff --git a/apps/mobile/src/lib/hooks/use-kiloclaw-queries.ts b/apps/mobile/src/lib/hooks/use-kiloclaw-queries.ts index 6ea2818ebd..2796de3bb6 100644 --- a/apps/mobile/src/lib/hooks/use-kiloclaw-queries.ts +++ b/apps/mobile/src/lib/hooks/use-kiloclaw-queries.ts @@ -289,24 +289,6 @@ export function useKiloClawSecretCatalog(organizationId?: string | null) { return isOrg ? org : personal; } -export function useStreamChatCredentials(organizationId?: string | null, enabled = true) { - const trpc = useTRPC(); - const { isOrg, personalEnabled, orgEnabled, orgInput } = resolveContext(organizationId, enabled); - const personal = useQuery( - trpc.kiloclaw.getStreamChatCredentials.queryOptions(undefined, { - enabled: personalEnabled, - staleTime: 5 * 60_000, - }) - ); - const org = useQuery( - trpc.organizations.kiloclaw.getStreamChatCredentials.queryOptions(orgInput, { - enabled: orgEnabled, - staleTime: 5 * 60_000, - }) - ); - return isOrg ? org : personal; -} - export function useKiloClawConfig(organizationId?: string | null) { const trpc = useTRPC(); const { isOrg, personalEnabled, orgEnabled, orgInput } = resolveContext(organizationId); 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..6570edcbb7 100644 --- a/apps/mobile/src/lib/hooks/use-unread-counts-invalidation.ts +++ b/apps/mobile/src/lib/hooks/use-unread-counts-invalidation.ts @@ -3,11 +3,12 @@ import * as Notifications from 'expo-notifications'; import { useEffect } from 'react'; import { AppState } from 'react-native'; +import { useCurrentUserId } from '@/components/kilo-chat/hooks/use-current-user-id'; +import { advanceBadgeFreshnessEpoch } from '@/lib/badge-freshness'; import { parseNotificationData } from '@/lib/notifications'; -import { useTRPC } from '@/lib/trpc'; /** - * Keeps the `user.getUnreadCounts` cache in sync with real-time notification + * Keeps the `['badges', userId]` cache in sync with real-time notification * traffic so per-instance badges on the dashboard reflect pushes received while * the app is open or resumed from background. * @@ -15,25 +16,29 @@ import { useTRPC } from '@/lib/trpc'; * - App returns to active state → invalidate (pushes received while * backgrounded don't fire the received-listener). * - * Mounted once inside `RootLayoutNav` so it covers every screen, including - * when the dashboard is not rendered yet. + * Mounted once inside the authenticated app layout so it can read the + * Kilo Chat current-user context while still covering dashboard and chat + * screens. */ export function useUnreadCountsInvalidation() { const queryClient = useQueryClient(); - const trpc = useTRPC(); + const userId = useCurrentUserId(); useEffect(() => { - // `trpc` is stable (memoized inside TRPCProvider) but `queryKey()` returns - // a fresh array on each call, so we resolve it inside each invalidation. + if (userId === null) { + return undefined; + } + const invalidate = () => { + advanceBadgeFreshnessEpoch(); void queryClient.invalidateQueries({ - queryKey: trpc.user.getUnreadCounts.queryKey(), + queryKey: ['badges', userId], }); }; const received = Notifications.addNotificationReceivedListener(notification => { const data = parseNotificationData(notification.request.content.data); - if (data?.type === 'chat') { + if (data?.type === 'chat.message') { invalidate(); } }); @@ -48,5 +53,5 @@ export function useUnreadCountsInvalidation() { received.remove(); appStateSubscription.remove(); }; - }, [queryClient, trpc]); + }, [queryClient, userId]); } diff --git a/apps/mobile/src/lib/hooks/use-unread-counts.ts b/apps/mobile/src/lib/hooks/use-unread-counts.ts index e69e93fdc9..0a3fc7b092 100644 --- a/apps/mobile/src/lib/hooks/use-unread-counts.ts +++ b/apps/mobile/src/lib/hooks/use-unread-counts.ts @@ -1,33 +1,66 @@ import { useQuery } from '@tanstack/react-query'; +import * as Notifications from 'expo-notifications'; import { useMemo } from 'react'; -import { useTRPC } from '@/lib/trpc'; +import { + type BadgeCountRow, + listBadgesResponseSchema, + parentBadgeBucketFor, +} from '@kilocode/notifications'; + +import { useCurrentUserId } from '@/components/kilo-chat/hooks/use-current-user-id'; +import { useKiloChatTokenGetter } from '@/components/kilo-chat/hooks/use-kilo-chat-token'; +import { readBadgeFreshnessEpoch } from '@/lib/badge-freshness'; +import { reconcileHydratedBadgeCount } from '@/lib/badge-hydration'; +import { NOTIFICATIONS_URL } from '@/lib/config'; /** - * Fetches per-channel unread message counts for the current user and returns - * a Map keyed by channelId for O(1) lookup from dashboard cards. For kiloclaw - * chats, `channelId` equals the instance's `sandboxId`. + * Fetches unread message counts for the current user from the notifications + * worker and returns a Map keyed by instance badge bucket for O(1) lookup from + * dashboard cards. Conversation buckets are summed into their parent instance + * bucket. * * Freshness is driven by invalidations, not polling: * - Foreground chat push → invalidate (see `use-unread-counts-invalidation`). * - App returns to active → invalidate. - * - `markChatRead` optimistically clears the relevant row. + * - `useMarkRead` clears the relevant row after Kilo Chat confirms the bucket clear. */ export function useUnreadCounts() { - const trpc = useTRPC(); - const query = useQuery( - trpc.user.getUnreadCounts.queryOptions(undefined, { - staleTime: 30_000, - }) - ); + const userId = useCurrentUserId(); + const getToken = useKiloChatTokenGetter(); + + const query = useQuery({ + queryKey: ['badges', userId], + enabled: userId !== null, + staleTime: 30_000, + queryFn: async () => { + const startBadgeFreshnessEpoch = readBadgeFreshnessEpoch(); + const token = await getToken(); + const response = await fetch(`${NOTIFICATIONS_URL}/v1/badges`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch badges: ${response.status}`); + } + const body = listBadgesResponseSchema.parse(await response.json()); + reconcileHydratedBadgeCount({ + badgeRows: body.buckets, + startBadgeFreshnessEpoch, + currentBadgeFreshnessEpoch: readBadgeFreshnessEpoch(), + setBadgeCount: Notifications.setBadgeCountAsync, + }); + return body.buckets; + }, + }); - const byChannel = useMemo(() => { + const byBadgeBucket = useMemo(() => { const map = new Map(); for (const row of query.data ?? []) { - map.set(row.channelId, row.badgeCount); + const aggregateBucket = parentBadgeBucketFor(row.badgeBucket); + map.set(aggregateBucket, (map.get(aggregateBucket) ?? 0) + row.badgeCount); } return map; }, [query.data]); - return { byChannel, query }; + return { byBadgeBucket, query }; } diff --git a/apps/mobile/src/lib/kilo-chat-routes.ts b/apps/mobile/src/lib/kilo-chat-routes.ts new file mode 100644 index 0000000000..5be5a64d74 --- /dev/null +++ b/apps/mobile/src/lib/kilo-chat-routes.ts @@ -0,0 +1,29 @@ +import { type Href } from 'expo-router'; + +const KILOCLAW_TAB_CHAT_ROOT = '/(app)/(tabs)/(1_kiloclaw)/chat'; + +export function chatSandboxRoute(sandboxId: string): string { + return `${KILOCLAW_TAB_CHAT_ROOT}/${sandboxId}`; +} + +export function chatConversationRoute(sandboxId: string, conversationId: string): string { + return `${KILOCLAW_TAB_CHAT_ROOT}/${sandboxId}/${conversationId}`; +} + +export function chatSandboxPath(sandboxId: string): Href { + return chatSandboxRoute(sandboxId) as Href; +} + +export function chatConversationPath(sandboxId: string, conversationId: string): Href { + return chatConversationRoute(sandboxId, conversationId) as Href; +} + +export function chatRenameConversationPath(sandboxId: string, params: URLSearchParams): Href { + const renameParams = new URLSearchParams(params); + renameParams.set('sandboxId', sandboxId); + return `/(app)/(tabs)/(1_kiloclaw)/rename-conversation?${renameParams.toString()}` as Href; +} + +export function chatInstancePickerPath(currentId: string): Href { + return `${KILOCLAW_TAB_CHAT_ROOT}/instance-picker?currentId=${currentId}` as Href; +} diff --git a/apps/mobile/src/lib/kiloclaw-display.test.ts b/apps/mobile/src/lib/kiloclaw-display.test.ts new file mode 100644 index 0000000000..036dabe3b5 --- /dev/null +++ b/apps/mobile/src/lib/kiloclaw-display.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { kiloclawConversationEyebrow, kiloclawInstanceSwitcherTitle } from './kiloclaw-display'; + +describe('KiloClaw display labels', () => { + it('uses the bot name above a conversation title', () => { + expect( + kiloclawConversationEyebrow({ + botName: 'Helper Bot', + name: 'Production instance', + organizationName: 'Engineering', + }) + ).toBe('Helper Bot'); + }); + + it('falls back when the conversation instance has no bot name', () => { + expect( + kiloclawConversationEyebrow({ + botName: null, + name: 'Production instance', + organizationName: 'Engineering', + }) + ).toBe('Production instance'); + + expect( + kiloclawConversationEyebrow({ + botName: null, + name: null, + organizationName: 'Engineering', + }) + ).toBe('Engineering'); + + expect(kiloclawConversationEyebrow(undefined)).toBe('KiloClaw'); + }); + + it('uses the bot name for instance switcher cards', () => { + expect( + kiloclawInstanceSwitcherTitle({ + botName: 'Deploy Bot', + name: 'Production instance', + organizationName: 'Engineering', + }) + ).toBe('Deploy Bot'); + }); + + it('falls back when an instance switcher card has no bot name', () => { + expect( + kiloclawInstanceSwitcherTitle({ + botName: null, + name: 'Production instance', + organizationName: 'Engineering', + }) + ).toBe('Production instance'); + + expect( + kiloclawInstanceSwitcherTitle({ + botName: null, + name: null, + organizationName: 'Engineering', + }) + ).toBe('Engineering'); + + expect(kiloclawInstanceSwitcherTitle(undefined)).toBe('KiloClaw instance'); + }); +}); diff --git a/apps/mobile/src/lib/kiloclaw-display.ts b/apps/mobile/src/lib/kiloclaw-display.ts new file mode 100644 index 0000000000..d754d1e898 --- /dev/null +++ b/apps/mobile/src/lib/kiloclaw-display.ts @@ -0,0 +1,28 @@ +type KiloClawDisplayInstance = { + botName?: string | null; + name?: string | null; + organizationName?: string | null; +}; + +function firstDisplayValue(values: readonly (string | null | undefined)[]): string | null { + for (const value of values) { + const trimmed = value?.trim(); + if (trimmed) { + return trimmed; + } + } + return null; +} + +export function kiloclawConversationEyebrow(instance: KiloClawDisplayInstance | undefined) { + return ( + firstDisplayValue([instance?.botName, instance?.name, instance?.organizationName]) ?? 'KiloClaw' + ); +} + +export function kiloclawInstanceSwitcherTitle(instance: KiloClawDisplayInstance | undefined) { + return ( + firstDisplayValue([instance?.botName, instance?.name, instance?.organizationName]) ?? + 'KiloClaw instance' + ); +} diff --git a/apps/mobile/src/lib/last-active-instance.test.ts b/apps/mobile/src/lib/last-active-instance.test.ts new file mode 100644 index 0000000000..8d1addd581 --- /dev/null +++ b/apps/mobile/src/lib/last-active-instance.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getItemAsync: vi.fn<() => Promise>(), + setItemAsync: vi.fn<(key: string, value: string) => Promise>(), +})); + +vi.mock('expo-secure-store', () => ({ + getItemAsync: mocks.getItemAsync, + setItemAsync: mocks.setItemAsync, +})); + +vi.mock('@/lib/storage-keys', () => ({ + LAST_ACTIVE_INSTANCE_KEY: 'last-active-chat-instance', +})); + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); +}); + +describe('last active instance', () => { + it('updates the in-memory fallback before persisting the sandbox id', async () => { + mocks.setItemAsync.mockResolvedValue(undefined); + const { getLastActiveInstance, setLastActiveInstance } = await import('./last-active-instance'); + + const write = setLastActiveInstance('sandbox-b'); + + expect(getLastActiveInstance()).toBe('sandbox-b'); + await write; + expect(mocks.setItemAsync).toHaveBeenCalledWith('last-active-chat-instance', 'sandbox-b'); + }); + + it('keeps an explicitly focused sandbox ahead of the initial load fallback', async () => { + mocks.getItemAsync.mockResolvedValue('sandbox-a'); + mocks.setItemAsync.mockResolvedValue(undefined); + const { getLastActiveInstance, loadLastActiveInstance, setLastActiveInstance } = + await import('./last-active-instance'); + + await setLastActiveInstance('sandbox-b'); + await loadLastActiveInstance(); + + expect(getLastActiveInstance()).toBe('sandbox-b'); + }); +}); diff --git a/apps/mobile/src/lib/last-active-instance.ts b/apps/mobile/src/lib/last-active-instance.ts index 05f9577d0f..4286a78962 100644 --- a/apps/mobile/src/lib/last-active-instance.ts +++ b/apps/mobile/src/lib/last-active-instance.ts @@ -13,7 +13,7 @@ export function getLastActiveInstance(): string | null { return cached; } -export function setLastActiveInstance(id: string): void { - cached = id; - void SecureStore.setItemAsync(LAST_ACTIVE_INSTANCE_KEY, id); +export async function setLastActiveInstance(sandboxId: string): Promise { + cached = sandboxId; + await SecureStore.setItemAsync(LAST_ACTIVE_INSTANCE_KEY, sandboxId); } diff --git a/apps/mobile/src/lib/notification-path.test.ts b/apps/mobile/src/lib/notification-path.test.ts new file mode 100644 index 0000000000..39c7478964 --- /dev/null +++ b/apps/mobile/src/lib/notification-path.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { pushDataSchema } from '@kilocode/notifications'; + +import { notificationPathForData } from './notification-path'; + +describe('notificationPathForData', () => { + it('routes chat message notifications to the conversation screen', () => { + expect( + notificationPathForData({ + type: 'chat.message', + sandboxId: 'sandbox-1', + conversationId: 'conversation-1', + messageId: 'message-1', + }) + ).toBe('/(app)/(tabs)/(1_kiloclaw)/chat/sandbox-1/conversation-1'); + }); + + it('keeps notifications on the tab-owned KiloClaw chat route', () => { + expect( + notificationPathForData({ + type: 'chat.message', + sandboxId: 'sandbox-1', + conversationId: 'conversation-1', + messageId: 'message-1', + }) + ).toContain('/(app)/(tabs)/(1_kiloclaw)/chat/sandbox-1/'); + }); + + it('routes ready lifecycle notifications with legacy sandbox IDs to the sandbox chat screen', () => { + expect( + notificationPathForData({ + type: 'instance-lifecycle', + event: 'ready', + sandboxId: 'abcDEF123_-', + }) + ).toBe('/(app)/(tabs)/(1_kiloclaw)/chat/abcDEF123_-'); + }); + + it('routes start_failed lifecycle notifications with ki sandbox IDs to the sandbox chat screen', () => { + expect( + notificationPathForData({ + type: 'instance-lifecycle', + event: 'start_failed', + sandboxId: 'ki_deadbeef', + }) + ).toBe('/(app)/(tabs)/(1_kiloclaw)/chat/ki_deadbeef'); + }); +}); + +describe('pushDataSchema', () => { + it('rejects empty chat notification IDs', () => { + expect( + pushDataSchema.safeParse({ + type: 'chat.message', + sandboxId: '', + conversationId: 'conversation-1', + messageId: 'message-1', + }).success + ).toBe(false); + expect( + pushDataSchema.safeParse({ + type: 'chat.message', + sandboxId: 'sandbox-1', + conversationId: '', + messageId: 'message-1', + }).success + ).toBe(false); + expect( + pushDataSchema.safeParse({ + type: 'chat.message', + sandboxId: 'sandbox-1', + conversationId: 'conversation-1', + messageId: '', + }).success + ).toBe(false); + }); + + it('accepts valid chat and lifecycle notification data', () => { + expect( + pushDataSchema.safeParse({ + type: 'chat.message', + sandboxId: 'sandbox-1', + conversationId: 'conversation-1', + messageId: 'message-1', + }).success + ).toBe(true); + expect( + pushDataSchema.safeParse({ + type: 'instance-lifecycle', + event: 'ready', + sandboxId: 'sandbox-1', + }).success + ).toBe(true); + }); +}); diff --git a/apps/mobile/src/lib/notification-path.ts b/apps/mobile/src/lib/notification-path.ts new file mode 100644 index 0000000000..90010a2dff --- /dev/null +++ b/apps/mobile/src/lib/notification-path.ts @@ -0,0 +1,10 @@ +import { type PushData } from '@kilocode/notifications'; + +import { chatConversationRoute, chatSandboxRoute } from './kilo-chat-routes'; + +export function notificationPathForData(data: PushData): string { + if (data.type === 'chat.message') { + return chatConversationRoute(data.sandboxId, data.conversationId); + } + return chatSandboxRoute(data.sandboxId); +} diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts index e524feedae..2fa8215ca2 100644 --- a/apps/mobile/src/lib/notifications.ts +++ b/apps/mobile/src/lib/notifications.ts @@ -2,7 +2,10 @@ 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 { type PushData, pushDataSchema } from '@kilocode/notifications'; + +import { notificationPathForData } from './notification-path'; function getProjectId(): string { const eas = expoConstants.expoConfig?.extra?.eas as { projectId?: string } | undefined; @@ -13,39 +16,24 @@ 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; } -// Keep in sync with the `data` payloads emitted by: -// - services/notifications/src/dos/NotificationChannelDO.ts (chat) -// - services/notifications/src/lib/notifications-service.ts (instance-lifecycle) -const notificationDataSchema = z.discriminatedUnion('type', [ - z.object({ - type: z.literal('chat'), - instanceId: z.string().min(1), - }), - z.object({ - type: z.literal('instance-lifecycle'), - event: z.enum(['ready', 'start_failed']), - instanceId: 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): PushData | null { + const parsed = pushDataSchema.safeParse(data); return parsed.success ? parsed.data : null; } @@ -71,8 +59,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 && data.instanceId === activeChatInstanceId) { + if ( + data?.type === 'chat.message' && + activeChatLocation?.sandboxId === data.sandboxId && + activeChatLocation.conversationId === data.conversationId + ) { return suppressed; } @@ -91,13 +82,11 @@ export function getPendingNotificationLink(): string | null { return link; } -function instanceChatPath(data: NotificationData | null): string | null { +function instanceChatPath(data: PushData | null): string | null { if (!data) { return null; } - // Both chat and instance-lifecycle payloads carry `instanceId` and deep-link - // to the same chat route. - return `/(app)/chat/${data.instanceId}`; + return notificationPathForData(data); } export function setupNotificationResponseHandler() { diff --git a/apps/mobile/src/lib/unread-counts-invalidation-mount.test.ts b/apps/mobile/src/lib/unread-counts-invalidation-mount.test.ts new file mode 100644 index 0000000000..dbeba3ee73 --- /dev/null +++ b/apps/mobile/src/lib/unread-counts-invalidation-mount.test.ts @@ -0,0 +1,191 @@ +import type * as ReactModule from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { KiloChatPresenceMount as kiloChatPresenceMount } from '@/components/kilo-chat/kilo-chat-presence-mount'; +import { useUnreadCountsInvalidation } from './hooks/use-unread-counts-invalidation'; + +type ReceivedNotification = { + request: { + content: { + data: unknown; + }; + }; +}; + +type AppStateStatus = 'active' | 'background' | 'inactive'; + +type TestState = { + appStateListeners: ((state: AppStateStatus) => void)[]; + cleanups: (() => void)[]; + currentUserId: string | null; + invalidatedKeys: unknown[]; + notificationListeners: ((notification: ReceivedNotification) => void)[]; +}; + +const testState = vi.hoisted(() => ({ + appStateListeners: [], + cleanups: [], + currentUserId: null, + invalidatedKeys: [], + notificationListeners: [], +})); + +const mocks = vi.hoisted(() => ({ + addAppStateListener: + vi.fn<(event: 'change', listener: (state: AppStateStatus) => void) => { remove: () => void }>(), + addNotificationReceivedListener: + vi.fn<(listener: (notification: ReceivedNotification) => void) => { remove: () => void }>(), + useAppPresence: vi.fn<() => void>(), +})); + +vi.mock('react', async () => { + const actual = await vi.importActual('react'); + return { + ...actual, + useEffect: (effect: ReactModule.EffectCallback) => { + const cleanup = effect(); + if (typeof cleanup === 'function') { + testState.cleanups.push(() => { + void cleanup(); + }); + } + }, + }; +}); + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ + invalidateQueries: ({ queryKey }: { queryKey: unknown }) => { + testState.invalidatedKeys.push(queryKey); + }, + }), +})); + +vi.mock('@/components/kilo-chat/hooks/use-app-presence', () => ({ + useAppPresence: mocks.useAppPresence, +})); + +vi.mock('@/components/kilo-chat/hooks/use-current-user-id', () => ({ + useCurrentUserId: () => testState.currentUserId, +})); + +vi.mock('expo-constants', () => ({ + default: { + expoConfig: { + extra: { + eas: { + projectId: 'project-1', + }, + }, + }, + }, +})); + +vi.mock('expo-notifications', () => ({ + addNotificationReceivedListener: mocks.addNotificationReceivedListener, + PermissionStatus: { + GRANTED: 'granted', + }, +})); + +vi.mock('expo-router', () => ({ + router: { + replace: vi.fn(), + }, +})); + +vi.mock('react-native', () => ({ + AppState: { + addEventListener: mocks.addAppStateListener, + }, + Platform: { + OS: 'ios', + }, +})); + +beforeEach(() => { + testState.appStateListeners = []; + testState.cleanups = []; + testState.currentUserId = null; + testState.invalidatedKeys = []; + testState.notificationListeners = []; + vi.clearAllMocks(); + mocks.addAppStateListener.mockImplementation((event, listener) => { + expect(event).toBe('change'); + testState.appStateListeners.push(listener); + return { + remove: () => { + testState.appStateListeners = testState.appStateListeners.filter( + appStateListener => appStateListener !== listener + ); + }, + }; + }); + mocks.addNotificationReceivedListener.mockImplementation(listener => { + testState.notificationListeners.push(listener); + return { + remove: () => { + testState.notificationListeners = testState.notificationListeners.filter( + notificationListener => notificationListener !== listener + ); + }, + }; + }); +}); + +afterEach(() => { + for (const cleanup of testState.cleanups) { + cleanup(); + } + vi.clearAllMocks(); +}); + +describe('KiloChatPresenceMount', () => { + it('mounts app presence and unread-count invalidation together', () => { + testState.currentUserId = 'user-1'; + + kiloChatPresenceMount({ children: null }); + + expect(mocks.useAppPresence).toHaveBeenCalledTimes(1); + expect(mocks.addNotificationReceivedListener).toHaveBeenCalledTimes(1); + expect(mocks.addAppStateListener).toHaveBeenCalledWith('change', expect.any(Function)); + }); +}); + +describe('useUnreadCountsInvalidation', () => { + it('does not register listeners when the current user context is unavailable', () => { + useUnreadCountsInvalidation(); + + expect(mocks.addNotificationReceivedListener).not.toHaveBeenCalled(); + expect(mocks.addAppStateListener).not.toHaveBeenCalled(); + }); + + it('invalidates the user badge query for foreground chat messages and app resume', () => { + testState.currentUserId = 'user-1'; + + useUnreadCountsInvalidation(); + + expect(mocks.addNotificationReceivedListener).toHaveBeenCalledTimes(1); + expect(mocks.addAppStateListener).toHaveBeenCalledWith('change', expect.any(Function)); + + testState.notificationListeners[0]?.({ + request: { + content: { + data: { + conversationId: 'conversation-1', + messageId: 'message-1', + sandboxId: 'sandbox-1', + type: 'chat.message', + }, + }, + }, + }); + testState.appStateListeners[0]?.('background'); + testState.appStateListeners[0]?.('active'); + + expect(testState.invalidatedKeys).toEqual([ + ['badges', 'user-1'], + ['badges', 'user-1'], + ]); + }); +}); diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts index 872ee679d3..892908c4d3 100644 --- a/apps/mobile/vitest.config.ts +++ b/apps/mobile/vitest.config.ts @@ -1,9 +1,20 @@ +import { fileURLToPath } from 'node:url'; + import { defineConfig } from 'vitest/config'; export default defineConfig({ + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, test: { name: 'mobile-onboarding', environment: 'node', - include: ['src/lib/onboarding/**/*.test.ts'], + include: [ + 'src/lib/*.test.ts', + 'src/lib/onboarding/**/*.test.ts', + 'src/components/**/*.test.ts', + ], }, }); diff --git a/apps/web/package.json b/apps/web/package.json index c1eaffc679..bf2974dfa5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -44,6 +44,7 @@ "@kilocode/encryption": "workspace:*", "@kilocode/event-service": "workspace:*", "@kilocode/kilo-chat": "workspace:*", + "@kilocode/kilo-chat-hooks": "workspace:*", "@kilocode/kiloclaw-secret-catalog": "workspace:*", "@kilocode/worker-utils": "workspace:*", "@lottiefiles/dotlottie-react": "^0.17.15", @@ -144,8 +145,6 @@ "remark-gfm": "^4.0.1", "server-only": "^0.0.1", "sonner": "^2.0.7", - "stream-chat": "^9.38.0", - "stream-chat-react": "^13.14.2", "stripe": "catalog:", "stytch": "^12.43.1", "tailwind-merge": "^3.5.0", diff --git a/apps/web/src/app/(app)/claw/chat/[conversationId]/page.tsx b/apps/web/src/app/(app)/claw/chat/[conversationId]/page.tsx new file mode 100644 index 0000000000..f33fc2e4ad --- /dev/null +++ b/apps/web/src/app/(app)/claw/chat/[conversationId]/page.tsx @@ -0,0 +1,3 @@ +import { KiloChatConversationPage } from '@/app/(app)/claw/kilo-chat/components/KiloChatConversationPage'; + +export default KiloChatConversationPage; diff --git a/apps/web/src/app/(app)/claw/chat/layout.tsx b/apps/web/src/app/(app)/claw/chat/layout.tsx new file mode 100644 index 0000000000..93ed3d139d --- /dev/null +++ b/apps/web/src/app/(app)/claw/chat/layout.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { useUser } from '@/hooks/useUser'; +import { useKiloClawStatus } from '@/hooks/useKiloClaw'; +import { KiloChatLayout } from '@/app/(app)/claw/kilo-chat/components/KiloChatLayout'; +import { BillingWrapper } from '@/app/(app)/claw/components/billing/BillingWrapper'; + +export default function ChatRootLayout({ children }: { children: React.ReactNode }) { + const { data: user } = useUser(); + const { data: status, error, isError, isLoading, refetch } = useKiloClawStatus(); + const instanceErrorMessage = + error instanceof Error ? error.message : error ? 'Unknown error' : null; + + const content = ( + void refetch()} + assistantName={status?.botName ?? null} + className="flex-1" + > + {children} + + ); + + return ( +
+ {content} +
+ ); +} diff --git a/apps/web/src/app/(app)/claw/chat/page.tsx b/apps/web/src/app/(app)/claw/chat/page.tsx index 6ea7f89514..0edf7f6f9b 100644 --- a/apps/web/src/app/(app)/claw/chat/page.tsx +++ b/apps/web/src/app/(app)/claw/chat/page.tsx @@ -1,7 +1,44 @@ 'use client'; -import { ClawChatPage } from '../components/ClawChatPage'; +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { MessagesSquare } from 'lucide-react'; +import { useKiloChatContext } from '@/app/(app)/claw/kilo-chat/components/kiloChatContext'; +import { KiloChatStatusError } from '@/app/(app)/claw/kilo-chat/components/KiloChatStatusError'; +import { kiloChatInstanceRouteDecision } from '@/app/(app)/claw/kilo-chat/[conversationId]/conversation-route-guard'; -export default function PersonalClawChatPage() { - return ; +export default function ChatIndexPage() { + const router = useRouter(); + const { + instanceErrorMessage, + instanceStatus, + isInstanceError, + isInstanceLoading, + noInstanceRedirect, + onRetryInstanceStatus, + } = useKiloChatContext(); + const routeDecision = kiloChatInstanceRouteDecision({ + instanceStatus, + isInstanceError, + isInstanceLoading, + }); + + useEffect(() => { + if (routeDecision === 'redirect-no-instance') { + router.replace(noInstanceRedirect); + } + }, [noInstanceRedirect, routeDecision, router]); + + if (routeDecision === 'status-error') { + return ; + } + + return ( +
+
+ +

Select a conversation or start a new one

+
+
+ ); } diff --git a/apps/web/src/app/(app)/claw/claw-chat.css b/apps/web/src/app/(app)/claw/claw-chat.css deleted file mode 100644 index e51d77aeac..0000000000 --- a/apps/web/src/app/(app)/claw/claw-chat.css +++ /dev/null @@ -1,153 +0,0 @@ -@import 'stream-chat-react/dist/css/v2/index.css'; - -/* ── Stream Chat theme overrides ────────────────────────────────────────────── - Stream Chat CSS is imported into layer(base) so these unlayered overrides - always win per the CSS cascade (unlayered > layered). - Scoped to .claw-chat-wrapper to avoid leaking outside the ChatTab. */ -.claw-chat-wrapper { - font-family: inherit; - border-radius: var(--radius-lg); - border: 1px solid oklch(1 0 0 / 6%); - background: oklch(0.269 0 0 / 0.2); - overflow: hidden; -} - -.claw-chat-wrapper .str-chat, -.claw-chat-wrapper .str-chat-channel, -.claw-chat-wrapper .str-chat__container { - height: 100%; -} - -.claw-chat-wrapper .str-chat { - /* ── Global theme: colors ─────────────────────────────────────────────── */ - --str-chat__primary-color: oklch(0.546 0.245 262.881); - --str-chat__active-primary-color: oklch(0.488 0.243 264.376); - --str-chat__primary-color-low-emphasis: oklch(0.546 0.245 262.881 / 0.3); - --str-chat__primary-overlay-color: oklch(0.546 0.245 262.881 / 0.6); - --str-chat__on-primary-color: oklch(0.985 0 0); - - --str-chat__background-color: transparent; - --str-chat__secondary-background-color: transparent; - - --str-chat__primary-surface-color: oklch(0.546 0.245 262.881 / 0.15); - --str-chat__primary-surface-color-low-emphasis: oklch(0.546 0.245 262.881 / 0.08); - --str-chat__surface-color: oklch(0.269 0 0 / 0.4); - --str-chat__secondary-surface-color: oklch(0.269 0 0 / 0.3); - --str-chat__tertiary-surface-color: oklch(0.269 0 0 / 0.2); - - --str-chat__text-color: oklch(0.985 0 0); - --str-chat__text-low-emphasis-color: oklch(0.708 0 0); - --str-chat__disabled-color: oklch(0.708 0 0); - --str-chat__on-disabled-color: oklch(0.985 0 0); - - --str-chat__danger-color: oklch(0.704 0.191 22.216); - --str-chat__info-color: oklch(0.696 0.17 162.48); - --str-chat__unread-badge-color: oklch(0.704 0.191 22.216); - --str-chat__on-unread-badge-color: oklch(0.985 0 0); - --str-chat__message-highlight-color: oklch(0.332 0.06 83); - - --str-chat__overlay-color: oklch(0 0 0 / 0.7); - --str-chat__secondary-overlay-color: oklch(0 0 0 / 0.4); - --str-chat__secondary-overlay-text-color: oklch(0.985 0 0); - --str-chat__opaque-surface-background-color: oklch(0.985 0 0 / 0.85); - --str-chat__opaque-surface-text-color: oklch(0.145 0 0); - --str-chat__box-shadow-color: oklch(0 0 0 / 0.8); - - /* ── Global theme: typography ─────────────────────────────────────────── */ - /* Note: `inherit` cannot be used as --str-chat__font-family because it's - a CSS-wide keyword that invalidates `font` shorthand substitution. - We use Inter directly to match the Kilo UI, with a system fallback. */ - --str-chat__font-family: Inter, ui-sans-serif, system-ui, sans-serif; - --str-chat__caption-text: 0.6875rem/1.3 var(--str-chat__font-family); - --str-chat__caption-medium-text: 500 0.6875rem/1.3 var(--str-chat__font-family); - --str-chat__caption-strong-text: 700 0.6875rem/1.3 var(--str-chat__font-family); - --str-chat__body-text: 0.8125rem/1.4 var(--str-chat__font-family); - --str-chat__body-medium-text: 500 0.8125rem/1.4 var(--str-chat__font-family); - --str-chat__body2-text: 0.875rem/1.4 var(--str-chat__font-family); - --str-chat__body2-medium-text: 500 0.875rem/1.4 var(--str-chat__font-family); - --str-chat__subtitle-text: 0.875rem/1.3 var(--str-chat__font-family); - --str-chat__subtitle-medium-text: 500 0.875rem/1.3 var(--str-chat__font-family); - --str-chat__subtitle2-text: 1rem/1.2 var(--str-chat__font-family); - --str-chat__subtitle2-medium-text: 500 1rem/1.2 var(--str-chat__font-family); - --str-chat__headline-text: 1.125rem/1.2 var(--str-chat__font-family); - --str-chat__headline2-text: 1.25rem/1.2 var(--str-chat__font-family); - - /* ── Global theme: border radius ──────────────────────────────────────── */ - --str-chat__border-radius-xs: 6px; - --str-chat__border-radius-sm: 8px; - --str-chat__border-radius-md: 10px; - --str-chat__border-radius-lg: 14px; - --str-chat__border-radius-circle: 999px; - - /* ── Component: message bubbles (badge-style: transparent bg + border) ── */ - --str-chat__message-bubble-background-color: transparent; - --str-chat__message-bubble-color: oklch(0.708 0 0); - --str-chat__message-bubble-border-block-start: 1px solid oklch(1 0 0 / 10%); - --str-chat__message-bubble-border-block-end: 1px solid oklch(1 0 0 / 10%); - --str-chat__message-bubble-border-inline-start: 1px solid oklch(1 0 0 / 10%); - --str-chat__message-bubble-border-inline-end: 1px solid oklch(1 0 0 / 10%); - --str-chat__message-bubble-border-radius: var(--str-chat__border-radius-md); - --str-chat__own-message-bubble-background-color: transparent; - --str-chat__own-message-bubble-color: oklch(0.708 0 0); - - /* ── Component: message input ─────────────────────────────────────────── */ - --str-chat__message-input-background-color: transparent; - --str-chat__message-input-color: oklch(0.985 0 0); - --str-chat__message-textarea-background-color: oklch(0.269 0 0 / 0.4); - --str-chat__message-textarea-border-block-start: 1px solid oklch(1 0 0 / 6%); - --str-chat__message-textarea-border-block-end: 1px solid oklch(1 0 0 / 6%); - --str-chat__message-textarea-border-inline-start: 1px solid oklch(1 0 0 / 6%); - --str-chat__message-textarea-border-inline-end: 1px solid oklch(1 0 0 / 6%); - --str-chat__message-textarea-color: oklch(0.985 0 0); - - /* ── Component: message list ──────────────────────────────────────────── */ - --str-chat__message-list-background-color: transparent; - --str-chat__message-list-color: oklch(0.985 0 0); - - /* ── Component: channel header ────────────────────────────────────────── */ - --str-chat__channel-header-background-color: transparent; - - /* ── Component: date separator ────────────────────────────────────────── */ - --str-chat__date-separator-color: oklch(0.708 0 0); - --str-chat__date-separator-line-color: oklch(1 0 0 / 10%); - - /* ── Component: message actions ───────────────────────────────────────── */ - --str-chat__message-actions-box-background-color: oklch(0.269 0 0 / 0.9); - --str-chat__message-actions-box-color: oklch(0.985 0 0); - --str-chat__message-actions-box-box-shadow: 0 4px 12px oklch(0 0 0 / 0.4); -} - -/* Constrain send button icon to 20x20 */ -.claw-chat-wrapper .str-chat__send-button svg { - width: 20px; - height: 20px; -} - -/* Hide bot sender name (long ID strings) */ -.claw-chat-wrapper .str-chat__message-simple-name { - display: none; -} - -/* ── Thinking indicator ────────────────────────────────────────────────────── */ -.claw-thinking-message { - display: flex; - align-items: center; - padding: 8px 16px; -} - -.claw-thinking-text { - font-style: italic; - font: var(--str-chat__body-text); - color: oklch(0.708 0 0); - animation: claw-thinking-pulse 1.5s ease-in-out infinite; -} - -@keyframes claw-thinking-pulse { - 0%, - 100% { - opacity: 0.4; - } - 50% { - opacity: 1; - } -} diff --git a/apps/web/src/app/(app)/claw/components/ChatTab.tsx b/apps/web/src/app/(app)/claw/components/ChatTab.tsx deleted file mode 100644 index 7ef6930f89..0000000000 --- a/apps/web/src/app/(app)/claw/components/ChatTab.tsx +++ /dev/null @@ -1,227 +0,0 @@ -'use client'; - -import { createContext, use, useCallback, useEffect, useState } from 'react'; -import type { Channel as StreamChannel, Event } from 'stream-chat'; -import { useQueryClient } from '@tanstack/react-query'; -import { MessageSquare, RotateCw } from 'lucide-react'; -import { - Chat, - Channel, - Window, - MessageList, - MessageInput, - MessageSimple, - Thread, - useCreateChatClient, - useChatContext, - useChannelStateContext, - useMessageContext, -} from 'stream-chat-react'; -import { useClawStreamChatCredentials } from '../hooks/useClawHooks'; -import { useTRPC } from '@/lib/trpc/utils'; -import { useClawContext } from './ClawContext'; - -const BotUserIdContext = createContext(''); - -type ChatTabProps = { - /** Only fetch credentials and connect when true (tab is active + instance running). */ - enabled: boolean; -}; - -export function ChatTab({ enabled }: ChatTabProps) { - const { data: creds, isLoading, error } = useClawStreamChatCredentials(enabled); - - if (!enabled) { - return ; - } - - if (isLoading) { - return ; - } - - if (error) { - return ; - } - - if (!creds) { - return ( -
-
- -
-
-

Chat requires an upgrade

-

- This instance was provisioned before chat was enabled. Use the{' '} - - - Upgrade to Latest - {' '} - button above to activate real-time chat with your KiloClaw bot. -

-
-
- ); - } - - return ; -} - -// ─── Internal components ──────────────────────────────────────────────────── - -function StreamChatUI({ - apiKey, - userId, - channelId, -}: { - apiKey: string; - userId: string; - channelId: string; -}) { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - const { organizationId } = useClawContext(); - - // Stable token provider that fetches a fresh short-lived token on every call. - // stream-chat-react calls this when the current token expires (via `exp` claim). - // Routes to the correct tRPC endpoint based on personal vs org context. - const tokenProvider = useCallback(async () => { - const opts = organizationId - ? trpc.organizations.kiloclaw.getStreamChatCredentials.queryOptions( - { organizationId }, - { staleTime: 0 } - ) - : trpc.kiloclaw.getStreamChatCredentials.queryOptions(undefined, { - staleTime: 0, - }); - const creds = await queryClient.fetchQuery(opts); - if (!creds?.userToken) { - throw new Error('Failed to fetch Stream Chat credentials'); - } - return creds.userToken; - }, [queryClient, trpc, organizationId]); - - const client = useCreateChatClient({ - apiKey, - tokenOrProvider: tokenProvider, - userData: { id: userId }, - }); - - const [channel, setChannel] = useState(); - - useEffect(() => { - if (!client) return; - const ch = client.channel('messaging', channelId); - let cancelled = false; - void (async () => { - await ch.watch({ presence: true }); - if (cancelled) return; - // Disable file uploads client-side by stripping the capability before - // Channel reads it. This hides the attachment button, disables drag- - // and-drop, and makes paste-to-upload a no-op — all three paths in - // stream-chat-react gate on channel.data.own_capabilities["upload-file"]. - if (ch.data?.own_capabilities) { - ch.data.own_capabilities = ch.data.own_capabilities.filter( - capability => capability !== 'upload-file' - ); - } - setChannel(ch); - })(); - return () => { - cancelled = true; - void ch.stopWatching(); - }; - }, [client, channelId]); - - // channelId is "default-{sandboxId}", bot user is "bot-{sandboxId}" - const sandboxId = channelId.replace(/^default-/, ''); - const botUserId = `bot-${sandboxId}`; - - if (!client || !channel) { - return ; - } - - return ( - -
- - - - - - - - - - -
-
- ); -} - -function ClawMessage() { - const botUserId = use(BotUserIdContext); - const { message } = useMessageContext(); - const isBotThinking = - message.user?.id === botUserId && !message.text?.trim() && !message.attachments?.length; - - if (isBotThinking) { - return ( -
- Thinking… -
- ); - } - - return ; -} - -function useBotOnlineStatus(botUserId: string): boolean { - const { client } = useChatContext(); - const { channel } = useChannelStateContext(); - - const getBotOnline = useCallback((): boolean => { - const member = channel.state.members[botUserId]; - return !!member?.user?.online; - }, [channel, botUserId]); - - const [online, setOnline] = useState(getBotOnline); - - useEffect(() => { - setOnline(getBotOnline()); - - const handlePresenceChange = (event: Event) => { - if (event.user?.id === botUserId) { - setOnline(!!event.user.online); - } - }; - - client.on('user.presence.changed', handlePresenceChange); - return () => { - client.off('user.presence.changed', handlePresenceChange); - }; - }, [client, botUserId, getBotOnline]); - - return online; -} - -function BotStatusBar({ botUserId }: { botUserId: string }) { - const online = useBotOnlineStatus(botUserId); - - return ( -
- - KiloClaw {online ? 'Online' : 'Offline'} -
- ); -} - -function ChatPlaceholder({ message, isError = false }: { message: string; isError?: boolean }) { - return ( -
- {message} -
- ); -} diff --git a/apps/web/src/app/(app)/claw/components/ClawChatPage.tsx b/apps/web/src/app/(app)/claw/components/ClawChatPage.tsx deleted file mode 100644 index 0483c52701..0000000000 --- a/apps/web/src/app/(app)/claw/components/ClawChatPage.tsx +++ /dev/null @@ -1,91 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; -import { MessageSquare } from 'lucide-react'; -import { useRouter } from 'next/navigation'; -import { useKiloClawStatus } from '@/hooks/useKiloClaw'; -import { useOrgKiloClawStatus } from '@/hooks/useOrgKiloClaw'; -import { ClawContextProvider } from './ClawContext'; -import { ChatTab } from './ChatTab'; -import { ClawConfigServiceBanner } from './ClawConfigServiceBanner'; -import { BillingWrapper } from './billing/BillingWrapper'; -import { SetPageTitle } from '@/components/SetPageTitle'; -import { Card, CardContent } from '@/components/ui/card'; - -/** - * Wrapper that polls status and handles loading/error/no-instance states - * before rendering the chat content. - */ -function ClawChatWithStatus({ organizationId }: { organizationId?: string }) { - const router = useRouter(); - const personalStatus = useKiloClawStatus(); - const orgStatus = useOrgKiloClawStatus(organizationId); - const { data: status, isLoading, error } = organizationId ? orgStatus : personalStatus; - - const clawUrl = organizationId ? `/organizations/${organizationId}/claw/new` : '/claw/new'; - - // Redirect to setup when there is no instance. - const shouldRedirect = !isLoading && !error && (!status || status.status === null); - useEffect(() => { - if (shouldRedirect) { - router.replace(clawUrl); - } - }, [shouldRedirect, clawUrl, router]); - - if (isLoading || shouldRedirect) { - return ( - - -

Loading…

-
-
- ); - } - - if (error) { - return ( - - -

- Failed to load status: {error instanceof Error ? error.message : 'Unknown error'} -

-
-
- ); - } - - if (!status || status.status === null) return null; - - const isRunning = status.status === 'running'; - const chatContent = ( - <> - - - - - - - - ); - - // Personal context uses BillingWrapper for access-lock dialogs/banners. - if (!organizationId) { - return {chatContent}; - } - - return chatContent; -} - -export function ClawChatPage({ organizationId }: { organizationId?: string }) { - return ( - -
- } - /> - -
-
- ); -} diff --git a/apps/web/src/app/(app)/claw/components/PersonalInstancePresenceMount.tsx b/apps/web/src/app/(app)/claw/components/PersonalInstancePresenceMount.tsx new file mode 100644 index 0000000000..d1236318ae --- /dev/null +++ b/apps/web/src/app/(app)/claw/components/PersonalInstancePresenceMount.tsx @@ -0,0 +1,10 @@ +'use client'; + +import { useInstancePresence } from '@/hooks/useInstancePresence'; +import { useKiloClawStatus } from '@/hooks/useKiloClaw'; + +export function PersonalInstancePresenceMount() { + const { data: status } = useKiloClawStatus(); + useInstancePresence(status?.sandboxId ?? undefined); + return null; +} diff --git a/apps/web/src/app/(app)/claw/components/billing/BillingBanner.tsx b/apps/web/src/app/(app)/claw/components/billing/BillingBanner.tsx index 8817d8dfbc..9e7c617398 100644 --- a/apps/web/src/app/(app)/claw/components/billing/BillingBanner.tsx +++ b/apps/web/src/app/(app)/claw/components/billing/BillingBanner.tsx @@ -196,7 +196,7 @@ export function BillingBanner({ return (
void; }) { return ( -
+
🦀
diff --git a/apps/web/src/app/(app)/claw/components/index.ts b/apps/web/src/app/(app)/claw/components/index.ts index 7d668b6fe2..7312409b29 100644 --- a/apps/web/src/app/(app)/claw/components/index.ts +++ b/apps/web/src/app/(app)/claw/components/index.ts @@ -7,7 +7,6 @@ export { ClawConfigServiceBannerWithStatus, } from './ClawConfigServiceBanner'; export { ClawHeader } from './ClawHeader'; -export { ClawChatPage } from './ClawChatPage'; export { ClawSettingsPage } from './ClawSettingsPage'; export { DetailTile } from './DetailTile'; export { InstanceTab } from './InstanceTab'; diff --git a/apps/web/src/app/(app)/claw/hooks/useClawHooks.ts b/apps/web/src/app/(app)/claw/hooks/useClawHooks.ts index cb336b77d2..a64f6de4bf 100644 --- a/apps/web/src/app/(app)/claw/hooks/useClawHooks.ts +++ b/apps/web/src/app/(app)/claw/hooks/useClawHooks.ts @@ -418,30 +418,6 @@ export function useClawGoogleSetupCommand(enabled: boolean) { return organizationId ? org : personal; } -// Stream Chat - -export function useClawStreamChatCredentials(enabled: boolean) { - const trpc = useTRPC(); - const { organizationId } = useClawContext(); - - const personal = useQuery({ - ...trpc.kiloclaw.getStreamChatCredentials.queryOptions(undefined, { - staleTime: 5 * 60_000, - }), - enabled: enabled && !organizationId, - }); - - const org = useQuery({ - ...trpc.organizations.kiloclaw.getStreamChatCredentials.queryOptions( - { organizationId: organizationId ?? '' }, - { staleTime: 5 * 60_000 } - ), - enabled: enabled && !!organizationId, - }); - - return organizationId ? org : personal; -} - // Kilo CLI Run export function useClawKiloCliRunStatus(runId: string | null) { diff --git a/apps/web/src/app/(app)/claw/kilo-chat/[conversationId]/conversation-route-guard.test.ts b/apps/web/src/app/(app)/claw/kilo-chat/[conversationId]/conversation-route-guard.test.ts new file mode 100644 index 0000000000..39ce475711 --- /dev/null +++ b/apps/web/src/app/(app)/claw/kilo-chat/[conversationId]/conversation-route-guard.test.ts @@ -0,0 +1,97 @@ +import { + kiloChatInstanceRouteDecision, + conversationRouteDecision, + conversationSandboxIdFromMembers, +} from './conversation-route-guard'; + +describe('kilo chat instance route decision', () => { + it('waits while instance status is loading', () => { + expect( + kiloChatInstanceRouteDecision({ + instanceStatus: null, + isInstanceError: false, + isInstanceLoading: true, + }) + ).toBe('pending'); + }); + + it('redirects to setup when status loaded successfully without an instance', () => { + expect( + kiloChatInstanceRouteDecision({ + instanceStatus: null, + isInstanceError: false, + isInstanceLoading: false, + }) + ).toBe('redirect-no-instance'); + }); + + it('is ready when status loaded successfully with an instance', () => { + expect( + kiloChatInstanceRouteDecision({ + instanceStatus: 'running', + isInstanceError: false, + isInstanceLoading: false, + }) + ).toBe('ready'); + }); + + it('surfaces status errors instead of redirecting to setup', () => { + expect( + kiloChatInstanceRouteDecision({ + instanceStatus: null, + isInstanceError: true, + isInstanceLoading: false, + }) + ).toBe('status-error'); + }); +}); + +describe('conversation route guard', () => { + it('derives the conversation sandbox from the KiloClaw bot member', () => { + expect( + conversationSandboxIdFromMembers([ + { id: 'user-1', kind: 'user' }, + { id: 'bot:kiloclaw:sandbox-conversation', kind: 'bot' }, + ]) + ).toBe('sandbox-conversation'); + }); + + it('redirects to the no-instance target once the route sandbox is known missing', () => { + expect( + conversationRouteDecision({ + conversationMembers: undefined, + isInstanceError: false, + isInstanceLoading: false, + isLeaving: false, + routeSandboxId: null, + }) + ).toBe('redirect-no-instance'); + }); + + it('blocks rendering when the loaded conversation belongs to another sandbox', () => { + expect( + conversationRouteDecision({ + conversationMembers: [ + { id: 'bot:kiloclaw:sandbox-conversation', kind: 'bot' }, + { id: 'user-1', kind: 'user' }, + ], + isInstanceError: false, + isInstanceLoading: false, + isLeaving: false, + routeSandboxId: 'sandbox-route', + }) + ).toBe('not-found'); + }); + + it('surfaces status errors instead of redirecting deep links to setup', () => { + expect( + conversationRouteDecision({ + conversationMembers: undefined, + isInstanceError: true, + isInstanceLoading: false, + isLeaving: false, + routeSandboxId: null, + }) + ).toBe('status-error'); + }); +}); diff --git a/apps/web/src/app/(app)/claw/kilo-chat/[conversationId]/conversation-route-guard.ts b/apps/web/src/app/(app)/claw/kilo-chat/[conversationId]/conversation-route-guard.ts new file mode 100644 index 0000000000..c602101ec4 --- /dev/null +++ b/apps/web/src/app/(app)/claw/kilo-chat/[conversationId]/conversation-route-guard.ts @@ -0,0 +1,65 @@ +import { conversationSandboxIdFromMembers, type ConversationMember } from '@kilocode/kilo-chat'; + +export type KiloChatInstanceRouteDecision = + | 'pending' + | 'ready' + | 'status-error' + | 'redirect-no-instance'; + +export type ConversationRouteDecision = + | 'pending' + | 'ready' + | 'status-error' + | 'not-found' + | 'redirect-no-instance'; + +export { conversationSandboxIdFromMembers }; + +export function kiloChatInstanceRouteDecision({ + instanceStatus, + isInstanceError, + isInstanceLoading, +}: { + instanceStatus: string | null; + isInstanceError: boolean; + isInstanceLoading: boolean; +}): KiloChatInstanceRouteDecision { + if (isInstanceLoading) { + return 'pending'; + } + if (isInstanceError) { + return 'status-error'; + } + return instanceStatus ? 'ready' : 'redirect-no-instance'; +} + +export function conversationRouteDecision({ + conversationMembers, + isInstanceError, + isInstanceLoading, + isLeaving, + routeSandboxId, +}: { + conversationMembers: ConversationMember[] | undefined; + isInstanceError: boolean; + isInstanceLoading: boolean; + isLeaving: boolean; + routeSandboxId: string | null; +}): ConversationRouteDecision { + if (isLeaving) { + return 'pending'; + } + if (isInstanceError) { + return 'status-error'; + } + if (routeSandboxId === null) { + return isInstanceLoading ? 'pending' : 'redirect-no-instance'; + } + if (conversationMembers === undefined) { + return 'pending'; + } + if (conversationSandboxIdFromMembers(conversationMembers) !== routeSandboxId) { + return 'not-found'; + } + return 'ready'; +} diff --git a/apps/web/src/app/(app)/claw/kilo-chat/[conversationId]/page.tsx b/apps/web/src/app/(app)/claw/kilo-chat/[conversationId]/page.tsx index d3ee4fa2a7..d19aeedc2e 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/[conversationId]/page.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/[conversationId]/page.tsx @@ -2,44 +2,14 @@ import { useEffect } from 'react'; import { useParams, useRouter } from 'next/navigation'; -import { toast } from 'sonner'; -import { KiloChatApiError } from '@kilocode/kilo-chat'; -import { useKiloChatContext } from '../components/kiloChatContext'; -import { useConversationDetail } from '../hooks/useConversations'; -import { MessageArea } from '../components/MessageArea'; -export default function KiloChatConversationPage() { +export default function LegacyKiloChatConversationPage() { const params = useParams<{ conversationId: string }>(); const router = useRouter(); - const { kiloChatClient, leavingConversationId, basePath } = useKiloChatContext(); - const isLeaving = leavingConversationId === params.conversationId; - const conversationDetail = useConversationDetail( - kiloChatClient, - isLeaving ? null : params.conversationId - ); useEffect(() => { - if (conversationDetail.isError && !isLeaving) { - const status = - conversationDetail.error instanceof KiloChatApiError - ? conversationDetail.error.status - : undefined; - const message = - status === 400 || status === 403 || status === 404 - ? 'Conversation not found' - : 'Failed to load conversation'; - toast.error(message); - router.replace(basePath); - } - }, [conversationDetail.isError, conversationDetail.error, isLeaving, router, basePath]); + router.replace(`/claw/chat/${params.conversationId}`); + }, [params.conversationId, router]); - if (isLeaving) { - return null; - } - - if (conversationDetail.isError) { - return null; - } - - return ; + return null; } diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/ConversationList.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/ConversationList.tsx index b9172c910f..8a02769951 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/ConversationList.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/ConversationList.tsx @@ -1,7 +1,7 @@ 'use client'; import { useCallback, useMemo } from 'react'; -import { Plus } from 'lucide-react'; +import { AlertTriangle, Loader2, Plus } from 'lucide-react'; import { useParams } from 'next/navigation'; import type { ConversationListItem } from '@kilocode/kilo-chat'; import { ConversationItem } from './ConversationItem'; @@ -48,17 +48,58 @@ type ConversationListProps = { isLoading: boolean; hasNextPage?: boolean; isFetchingNextPage?: boolean; + isCreatingConversation?: boolean; + newConversationError?: string | null; onLoadMore?: () => void; onNewConversation: () => void; onRename: (id: string, title: string) => void; onLeave: (id: string) => void; }; +type NewConversationUiState = { + buttonLabel: string; + buttonTitle: string; + disabled: boolean; + emptyText: string; + showError: boolean; +}; + +export function buildNewConversationUiState({ + isCreatingConversation, + newConversationError, +}: { + isCreatingConversation: boolean; + newConversationError: string | null; +}): NewConversationUiState { + if (isCreatingConversation) { + return { + buttonLabel: 'Creating conversation', + buttonTitle: 'Creating conversation', + disabled: true, + emptyText: 'Creating conversation...', + showError: false, + }; + } + + return { + buttonLabel: 'New conversation', + buttonTitle: 'New conversation', + disabled: false, + emptyText: + newConversationError === null + ? 'No conversations yet' + : 'No conversations yet. Create one to start chatting.', + showError: newConversationError !== null, + }; +} + export function ConversationList({ conversations, isLoading, hasNextPage, isFetchingNextPage, + isCreatingConversation = false, + newConversationError = null, onLoadMore, onNewConversation, onRename, @@ -67,6 +108,10 @@ export function ConversationList({ const params = useParams<{ conversationId?: string }>(); const activeId = params?.conversationId; const groups = useMemo(() => groupConversations(conversations), [conversations]); + const newConversationUi = buildNewConversationUiState({ + isCreatingConversation, + newConversationError, + }); const handleScroll = useCallback( (e: React.UIEvent) => { @@ -85,21 +130,34 @@ export function ConversationList({ Conversations
+ {newConversationUi.showError && ( +
+ + {newConversationError} +
+ )} +
{isLoading ? (
Loading...
) : conversations.length === 0 ? (
- No conversations yet + {newConversationUi.emptyText}
) : ( <> diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatConversationPage.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatConversationPage.tsx new file mode 100644 index 0000000000..cb064407ab --- /dev/null +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatConversationPage.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import { KiloChatApiError } from '@kilocode/kilo-chat'; +import { useKiloChatContext } from './kiloChatContext'; +import { useConversationDetail } from '../hooks/useConversations'; +import { MessageArea } from './MessageArea'; +import { KiloChatStatusError } from './KiloChatStatusError'; +import { conversationRouteDecision } from '../[conversationId]/conversation-route-guard'; + +export function KiloChatConversationPage() { + const params = useParams<{ conversationId: string }>(); + const router = useRouter(); + const { + kiloChatClient, + leavingConversationId, + basePath, + sandboxId, + isInstanceError, + instanceErrorMessage, + isInstanceLoading, + noInstanceRedirect, + onRetryInstanceStatus, + } = useKiloChatContext(); + const isLeaving = leavingConversationId === params.conversationId; + const conversationDetail = useConversationDetail( + kiloChatClient, + isLeaving || isInstanceError ? null : params.conversationId + ); + const routeDecision = conversationRouteDecision({ + conversationMembers: conversationDetail.data?.members, + isInstanceError, + isInstanceLoading, + isLeaving, + routeSandboxId: sandboxId, + }); + + useEffect(() => { + if (routeDecision === 'redirect-no-instance') { + router.replace(noInstanceRedirect); + return; + } + if (routeDecision === 'not-found') { + toast.error('Conversation not found'); + router.replace(basePath); + return; + } + if (conversationDetail.isError && !isLeaving) { + const status = + conversationDetail.error instanceof KiloChatApiError + ? conversationDetail.error.status + : undefined; + const message = + status === 400 || status === 403 || status === 404 + ? 'Conversation not found' + : 'Failed to load conversation'; + toast.error(message); + router.replace(basePath); + } + }, [ + conversationDetail.isError, + conversationDetail.error, + isLeaving, + router, + basePath, + noInstanceRedirect, + routeDecision, + ]); + + if (isLeaving || routeDecision !== 'ready') { + if (routeDecision === 'status-error') { + return ; + } + return null; + } + + if (conversationDetail.isError) { + return null; + } + + return ; +} diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx index 8ab3d6b2eb..f62c314ece 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx @@ -5,191 +5,158 @@ import { useRouter, useParams } from 'next/navigation'; import { toast } from 'sonner'; import { useQueryClient } from '@tanstack/react-query'; import { formatKiloChatError } from '@kilocode/kilo-chat'; +import { usePresenceSubscription } from '@kilocode/kilo-chat-hooks'; import { ConversationList } from './ConversationList'; import { KiloChatContext, type KiloChatContextValue } from './kiloChatContext'; -import { useEventService, useInstanceContext } from '../hooks/useEventService'; +import { kiloclawInstanceContext } from '@kilocode/event-service'; +import { useEventServiceClient } from '@/contexts/EventServiceContext'; +import { cn } from '@/lib/utils'; import { useConversations, useCreateConversation, useRenameConversation, useLeaveConversation, - updateConversationPages, - filterConversationPages, - type ConversationListInfiniteData, + conversationsKey, + registerConversationListCacheHandlers, } from '../hooks/useConversations'; // ── Layout component ──────────────────────────────────────────────── type KiloChatLayoutProps = { - getToken: () => Promise; - currentUserId: string; + currentUserId: string | null; sandboxId: string | null; basePath: string; noInstanceRedirect: string; isInstanceLoading: boolean; + isInstanceError: boolean; + instanceErrorMessage: string | null; + onRetryInstanceStatus: () => void; instanceStatus: string | null; assistantName: string | null; + className?: string; children: React.ReactNode; }; export function KiloChatLayout({ - getToken, currentUserId, sandboxId, basePath, noInstanceRedirect, isInstanceLoading, + isInstanceError, + instanceErrorMessage, + onRetryInstanceStatus, instanceStatus, assistantName, + className, children, }: KiloChatLayoutProps) { const router = useRouter(); - const { eventService, kiloChatClient } = useEventService(getToken); - useInstanceContext(eventService, sandboxId); + const { eventService, kiloChatClient } = useEventServiceClient(); + usePresenceSubscription( + sandboxId ? kiloclawInstanceContext(sandboxId) : null, + Boolean(sandboxId) + ); const queryClient = useQueryClient(); const params = useParams<{ conversationId?: string }>(); const [leavingConversationId, setLeavingConversationId] = useState(null); + const conversationsQueryKey = useMemo(() => conversationsKey(sandboxId), [sandboxId]); const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useConversations( kiloChatClient, sandboxId ); - // Update conversation list cache in-place when activity events arrive. - // For cursor pagination, events targeting conversations outside page 1 are - // ignored by an in-place patch, so the list appears stale. Invalidate the - // cache so the affected conversation either appears at the top (new/active) - // or re-sorts correctly once refetched. + // Update loaded conversation-list cache rows in-place when instance events arrive. + // Unknown conversations still invalidate so they can be fetched into the list. useEffect(() => { - const queryKey = ['kilo-chat', 'conversations']; - - function isOnFirstPage(conversationId: string): boolean { - const entries = queryClient.getQueriesData({ queryKey }); - for (const [, data] of entries) { - const firstPage = data?.pages[0]; - if (firstPage?.conversations.some(c => c.conversationId === conversationId)) { - return true; - } - } - return false; - } - - const offs = [ - kiloChatClient.onConversationCreated((_ctx, e) => { - if (isOnFirstPage(e.conversationId)) return; - void queryClient.invalidateQueries({ queryKey }); - }), - kiloChatClient.onConversationRenamed((_ctx, e) => { - queryClient.setQueriesData({ queryKey }, old => - updateConversationPages(old, c => - c.conversationId === e.conversationId ? { ...c, title: e.title } : c - ) - ); - // Also update the conversation detail cache if it's loaded - void queryClient.invalidateQueries({ - queryKey: ['kilo-chat', 'conversation', e.conversationId], - }); - }), - kiloChatClient.onConversationLeft((_ctx, e) => { - queryClient.setQueriesData({ queryKey }, old => - filterConversationPages(old, c => c.conversationId !== e.conversationId) - ); - }), - kiloChatClient.onConversationRead((_ctx, e) => { - // `.read` is broadcast to every human in the conversation with the - // `memberId` of whose read-marker moved. Only the actual reader - // should see their own sidebar row's `lastReadAt` advance — without - // this filter, Alice marking read would also move Bob's `lastReadAt`. - if (e.memberId !== currentUserId) return; - queryClient.setQueriesData({ queryKey }, old => - updateConversationPages(old, c => - c.conversationId === e.conversationId ? { ...c, lastReadAt: e.lastReadAt } : c - ) - ); - }), - kiloChatClient.onConversationActivity((_ctx, e) => { - if (isOnFirstPage(e.conversationId)) { - queryClient.setQueriesData({ queryKey }, old => - updateConversationPages(old, c => - c.conversationId === e.conversationId ? { ...c, lastActivityAt: e.lastActivityAt } : c - ) - ); - return; - } - void queryClient.invalidateQueries({ queryKey }); - }), - ]; - return () => offs.forEach(off => off()); - }, [kiloChatClient, queryClient]); - - // Refetch conversations on WebSocket reconnect (events may have been missed) - useEffect(() => { - return eventService.onReconnect(() => { - void queryClient.invalidateQueries({ queryKey: ['kilo-chat', 'conversations'] }); + return registerConversationListCacheHandlers({ + activeConversationId: params?.conversationId ?? null, + currentUserId, + eventService, + kiloChatClient, + queryClient, + queryKey: conversationsQueryKey, + sandboxId, }); - }, [eventService, queryClient]); + }, [ + currentUserId, + eventService, + kiloChatClient, + params?.conversationId, + queryClient, + conversationsQueryKey, + sandboxId, + ]); const createConversation = useCreateConversation(kiloChatClient); const renameConversation = useRenameConversation(kiloChatClient); const leaveConversation = useLeaveConversation(kiloChatClient); + const [newConversationError, setNewConversationError] = useState(null); const handleRename = useCallback( (conversationId: string, title: string) => { renameConversation.mutate( - { conversationId, title }, + { sandboxId, conversationId, title }, { onError: err => toast.error(formatKiloChatError(err, 'Failed to rename conversation')) } ); }, - [renameConversation.mutate] + [sandboxId, renameConversation.mutate] ); const handleLeave = useCallback( (conversationId: string) => { - // Mark as leaving so child queries disable themselves immediately + const isActiveConversation = params?.conversationId === conversationId; setLeavingConversationId(conversationId); - const queryKey = ['kilo-chat', 'conversations']; - // Optimistically remove the row before the router.push fires. When the - // user leaves the *active* conversation, router navigation concurrent - // with the mutation's onSuccess invalidateQueries left the row stale - // in the sidebar until a full page reload. Patching the cache up-front - // mirrors what onConversationLeft does for other members. - const previous = queryClient.getQueriesData({ queryKey }); - queryClient.setQueriesData({ queryKey }, old => - filterConversationPages(old, c => c.conversationId !== conversationId) + leaveConversation.mutate( + { sandboxId, conversationId }, + { + onSettled: () => setLeavingConversationId(null), + onSuccess: () => { + if (isActiveConversation) { + router.push(basePath); + } + }, + onError: err => { + toast.error(formatKiloChatError(err, 'Failed to leave conversation')); + }, + } ); - if (params?.conversationId === conversationId) { - router.push(basePath); - } - leaveConversation.mutate(conversationId, { - onSettled: () => setLeavingConversationId(null), - onError: err => { - // Restore the row on failure so the user can retry - for (const [key, data] of previous) { - queryClient.setQueryData(key, data); - } - toast.error(formatKiloChatError(err, 'Failed to leave conversation')); - }, - }); }, - [leaveConversation.mutate, params?.conversationId, queryClient, router, basePath] + [sandboxId, leaveConversation.mutate, params?.conversationId, router, basePath] ); const handleNewConversation = useCallback(() => { - if (!sandboxId) return; + if (!sandboxId || createConversation.isPending) return; + setNewConversationError(null); createConversation.mutate( { sandboxId }, { onSuccess: res => { + setNewConversationError(null); router.push(`${basePath}/${res.conversationId}`); }, - onError: err => toast.error(formatKiloChatError(err, 'Failed to create conversation')), + onError: err => { + const message = formatKiloChatError( + err, + "Couldn't create conversation. Check your connection and try again." + ); + setNewConversationError(message); + toast.error(message); + }, } ); - }, [sandboxId, basePath, createConversation.mutate, router]); + }, [ + sandboxId, + basePath, + createConversation.isPending, + createConversation.mutate, + router, + setNewConversationError, + ]); const contextValue = useMemo( () => ({ - getToken, currentUserId, instanceStatus, leavingConversationId, @@ -198,11 +165,13 @@ export function KiloChatLayout({ basePath, noInstanceRedirect, isInstanceLoading, + isInstanceError, + instanceErrorMessage, + onRetryInstanceStatus, eventService, kiloChatClient, }), [ - getToken, currentUserId, instanceStatus, leavingConversationId, @@ -211,6 +180,9 @@ export function KiloChatLayout({ basePath, noInstanceRedirect, isInstanceLoading, + isInstanceError, + instanceErrorMessage, + onRetryInstanceStatus, eventService, kiloChatClient, ] @@ -218,7 +190,7 @@ export function KiloChatLayout({ return ( -
+
{/* Conversation sidebar */}
void fetchNextPage()} onNewConversation={handleNewConversation} onRename={handleRename} diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatStatusError.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatStatusError.tsx new file mode 100644 index 0000000000..a557562668 --- /dev/null +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatStatusError.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { AlertCircle, RefreshCw } from 'lucide-react'; + +type KiloChatStatusErrorProps = { + message: string | null; + onRetry: () => void; +}; + +export function KiloChatStatusError({ message, onRetry }: KiloChatStatusErrorProps) { + return ( +
+
+ +
+

Failed to load status

+

{message ?? 'Please try again.'}

+
+ +
+
+ ); +} diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx index 66beb8b8c7..cef6ca9f0c 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx @@ -3,7 +3,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { ulid } from 'ulid'; -import type { Message, ContentBlock, ExecApprovalDecision } from '@kilocode/kilo-chat'; +import type { + Message, + ContentBlock, + EditMessageRequest, + ExecApprovalDecision, +} from '@kilocode/kilo-chat'; import { useMessages, useSendMessage, @@ -13,13 +18,23 @@ import { useAddReaction, useRemoveReaction, useExecuteAction, + latestMarkReadMessageId, } from '../hooks/useMessages'; -import { useConversationContext } from '../hooks/useEventService'; +import { + kiloclawConversationContext, + presenceContextForConversation, +} from '@kilocode/event-service'; +import { useDocumentVisible } from '@/hooks/useDocumentVisible'; import { useTypingSender, useTypingState } from '../hooks/useTyping'; import { + createMarkReadState, + finishMarkReadAttempt, useConversationDetail, useRenameConversation, useMarkConversationRead, + shouldStartMarkReadAttempt, + startMarkReadAttempt, + succeedMarkReadAttempt, } from '../hooks/useConversations'; import { useKiloChatContext } from './kiloChatContext'; import { toast } from 'sonner'; @@ -30,17 +45,47 @@ import { BotStatus, computeBotDisplay, useNowTicker } from './BotStatus'; import { ContextUsageRing } from './ContextUsageRing'; import { useBotStatus } from '../hooks/useBotStatus'; import { useConversationStatus } from '../hooks/useConversationStatus'; +import { + clearMarkReadRetry, + createMarkReadRetryState, + scheduleMarkReadRetry, + usePresenceSubscription, +} from '@kilocode/kilo-chat-hooks'; import { KiloChatApiError, formatKiloChatError, CONVERSATION_TITLE_MAX_CHARS, } from '@kilocode/kilo-chat'; +import { + clearPendingAction, + pendingActionGroupIdForMessage, + tryStartPendingAction, + type PendingAction, +} from '@kilocode/kilo-chat-hooks'; +import { + applyPrependScrollAnchor, + capturePrependScrollAnchor, + type PrependScrollAnchorSnapshot, +} from './message-scroll-anchor'; import { MessageCircle, ArrowDown } from 'lucide-react'; type MessageAreaProps = { conversationId: string; }; +function toEditableContent(content: ContentBlock[]): EditMessageRequest['content'] { + return content.map(block => { + if (block.type === 'actions') { + return { + type: 'actions', + groupId: block.groupId, + actions: block.actions, + }; + } + return block; + }); +} + export function MessageArea({ conversationId }: MessageAreaProps) { const { currentUserId, instanceStatus, assistantName, sandboxId, eventService, kiloChatClient } = useKiloChatContext(); @@ -59,35 +104,57 @@ export function MessageArea({ conversationId }: MessageAreaProps) { // which is a normal steady state. Only block sends once the bot is clearly // `offline` (>90 s stale, explicitly offline, or instance not running) or // `unknown` (no presence data at all). - const canSend = botDisplay.state === 'online' || botDisplay.state === 'idle'; + const botCanSend = botDisplay.state === 'online' || botDisplay.state === 'idle'; + const canSend = currentUserId !== null && botCanSend; const sendDisabledReason = canSend ? null - : botDisplay.state === 'unknown' - ? 'Waiting for bot status…' - : 'Bot is offline — messages will resume when it reconnects'; + : currentUserId === null + ? 'Loading user...' + : botDisplay.state === 'unknown' + ? 'Waiting for bot status…' + : 'Bot is offline — messages will resume when it reconnects'; const scrollRef = useRef(null); const contentRef = useRef(null); const autoScrollRef = useRef(true); + const pendingPrependScrollAnchorRef = useRef(null); + const wasFetchingNextPageRef = useRef(false); const [showScrollButton, setShowScrollButton] = useState(false); const [replyingTo, setReplyingTo] = useState(null); const [pendingDeleteId, setPendingDeleteId] = useState(null); const [isRenamingTitle, setIsRenamingTitle] = useState(false); const [renameText, setRenameText] = useState(''); + const [pendingAction, setPendingAction] = useState(null); + const pendingActionRef = useRef(null); + + const visible = useDocumentVisible(); + + // Subscribe to this conversation's chat-event stream while the conversation + // is open. Not gated on visibility — we want incoming messages to land in + // the cache even when the tab is hidden. + usePresenceSubscription( + sandboxId && conversationId ? kiloclawConversationContext(sandboxId, conversationId) : null, + Boolean(sandboxId && conversationId) + ); - // Subscribe to this conversation's events via the event-service WebSocket - useConversationContext(eventService, sandboxId, conversationId); + // Signal our own presence on this conversation. Gated on visibility so we + // only appear "viewing" while the tab is actually in the foreground. + usePresenceSubscription( + sandboxId && conversationId ? presenceContextForConversation(sandboxId, conversationId) : null, + Boolean(sandboxId && conversationId) && visible + ); // Event Service delivers subscribed contexts to every handler, so each // handler must validate the incoming `ctx` against this string before // applying changes to the active conversation's state. - const expectedContext = sandboxId ? `/kiloclaw/${sandboxId}/${conversationId}` : null; + const expectedContext = sandboxId ? kiloclawConversationContext(sandboxId, conversationId) : null; const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useMessages( kiloChatClient, conversationId ); const messages = data?.messages ?? []; + const latestMessageId = latestMarkReadMessageId(messages); const conversationDetail = useConversationDetail(kiloChatClient, conversationId); const renameConversation = useRenameConversation(kiloChatClient); @@ -108,27 +175,95 @@ export function MessageArea({ conversationId }: MessageAreaProps) { // Bots are excluded inside the hook because their streaming uses // message.created for every token chunk and relies on typing.stopped to // signal stream completion. - useMessageCacheUpdater(kiloChatClient, sandboxId, conversationId, clearTypingForMember); + const handleActionFailed = useCallback(() => { + toast.error("Couldn't reach the bot — please try again"); + }, []); + const handleMessageDeliveryFailed = useCallback(() => { + toast.error('Message could not be delivered to the bot'); + }, []); + useMessageCacheUpdater( + kiloChatClient, + sandboxId, + conversationId, + clearTypingForMember, + handleActionFailed, + handleMessageDeliveryFailed + ); const sendTyping = useTypingSender(kiloChatClient, conversationId); const markRead = useMarkConversationRead(kiloChatClient); - const lastMarkedRef = useRef(null); + const markReadStateRef = useRef(createMarkReadState()); + const markReadRetryStateRef = useRef(createMarkReadRetryState()); + const currentMarkReadMarker = + latestMessageId === null ? null : `${conversationId}:${latestMessageId}`; + const currentMarkReadMarkerRef = useRef(currentMarkReadMarker); + const visibleRef = useRef(visible); + const markCurrentConversationReadRef = useRef<() => void>(() => {}); + currentMarkReadMarkerRef.current = currentMarkReadMarker; + visibleRef.current = visible; + + const markCurrentConversationRead = useCallback(() => { + if (latestMessageId === null || currentMarkReadMarker === null) { + return; + } + const marker = currentMarkReadMarker; + const state = markReadStateRef.current; + if (!shouldStartMarkReadAttempt(state, marker)) { + return; + } + startMarkReadAttempt(state, marker); + markRead.mutate( + { sandboxId, conversationId, lastSeenMessageId: latestMessageId }, + { + onSuccess: () => { + succeedMarkReadAttempt(state, marker); + clearMarkReadRetry(markReadRetryStateRef.current); + }, + onSettled: () => { + finishMarkReadAttempt(state, marker); + if (state.lastSucceededMarker !== marker) { + scheduleMarkReadRetry(markReadRetryStateRef.current, { + marker, + currentMarker: () => currentMarkReadMarkerRef.current, + isActive: () => visibleRef.current, + lastSucceededMarker: () => markReadStateRef.current.lastSucceededMarker, + retry: () => markCurrentConversationReadRef.current(), + }); + } + }, + } + ); + }, [conversationId, currentMarkReadMarker, latestMessageId, markRead.mutate, sandboxId]); + markCurrentConversationReadRef.current = markCurrentConversationRead; + + useEffect(() => { + if (!visible || currentMarkReadMarker === null) { + clearMarkReadRetry(markReadRetryStateRef.current); + return; + } + if ( + markReadRetryStateRef.current.marker !== null && + markReadRetryStateRef.current.marker !== currentMarkReadMarker + ) { + clearMarkReadRetry(markReadRetryStateRef.current); + } + }, [currentMarkReadMarker, visible]); + + useEffect(() => { + return () => clearMarkReadRetry(markReadRetryStateRef.current); + }, []); - // Mark conversation as read when opened. react-query's mutate is stable - // across renders, so including it in deps is safe. + // Mark conversation as read when opened and whenever visible hydration or + // realtime receipt advances the newest message. useEffect(() => { - if (lastMarkedRef.current === conversationId) return; - lastMarkedRef.current = conversationId; - markRead.mutate(conversationId); - }, [conversationId, markRead.mutate]); + if (!visible) return; + markCurrentConversationRead(); + }, [markCurrentConversationRead, visible]); // Register side-effect handlers that don't mutate the message cache // (cache updates are handled by useMessageCacheUpdater). useEffect(() => { const offs = [ - kiloChatClient.onMessageDeliveryFailed(() => { - toast.error('Message could not be delivered to the bot'); - }), kiloChatClient.onTyping((ctx, data) => { handleTypingEvent(ctx, data); }), @@ -143,8 +278,11 @@ export function MessageArea({ conversationId }: MessageAreaProps) { useEffect(() => { return eventService.onReconnect(() => { void queryClient.invalidateQueries({ queryKey: ['kilo-chat', 'messages', conversationId] }); + if (visible) { + markCurrentConversationRead(); + } }); - }, [eventService, queryClient, conversationId]); + }, [conversationId, eventService, markCurrentConversationRead, queryClient, visible]); // Auto-scroll whenever content height changes (new messages, streaming // updates, image loads). A ResizeObserver on the inner content fires only @@ -163,6 +301,29 @@ export function MessageArea({ conversationId }: MessageAreaProps) { return () => observer.disconnect(); }, []); + useEffect(() => { + const wasFetchingNextPage = wasFetchingNextPageRef.current; + wasFetchingNextPageRef.current = isFetchingNextPage; + + if (!wasFetchingNextPage || isFetchingNextPage) { + return; + } + + const snapshot = pendingPrependScrollAnchorRef.current; + pendingPrependScrollAnchorRef.current = null; + if (!snapshot) { + return; + } + + const frameId = requestAnimationFrame(() => { + const el = scrollRef.current; + if (!el) return; + applyPrependScrollAnchor(el, snapshot); + }); + + return () => cancelAnimationFrame(frameId); + }, [isFetchingNextPage]); + // Track scroll position to detect user scrolling away from bottom function handleScroll() { const el = scrollRef.current; @@ -170,6 +331,7 @@ export function MessageArea({ conversationId }: MessageAreaProps) { // Load more on scroll to top if (el.scrollTop < 50 && hasNextPage && !isFetchingNextPage) { + pendingPrependScrollAnchorRef.current = capturePrependScrollAnchor(el); void fetchNextPage(); } @@ -192,38 +354,45 @@ export function MessageArea({ conversationId }: MessageAreaProps) { } const handleSend = useCallback( - (text: string, inReplyToMessageId?: string) => { + async (text: string, inReplyToMessageId?: string): Promise => { autoScrollRef.current = true; setShowScrollButton(false); - sendMessage.mutate( - { + try { + await sendMessage.mutateAsync({ conversationId, content: [{ type: 'text', text }], inReplyToMessageId, clientId: ulid(), - }, - { onError: err => toast.error(formatKiloChatError(err, 'Failed to send message')) } - ); + }); + return true; + } catch (err) { + toast.error(formatKiloChatError(err, 'Failed to send message')); + return false; + } }, - [sendMessage.mutate, conversationId] + [sendMessage.mutateAsync, conversationId] ); const handleEdit = useCallback( - (messageId: string, content: ContentBlock[]) => { - editMessage.mutate( - { messageId, conversationId, content, timestamp: Date.now() }, - { - onError: err => { - if (err instanceof KiloChatApiError && err.status === 409) { - toast.error('Message was edited by someone else — please try again'); - return; - } - toast.error(formatKiloChatError(err, 'Failed to edit message')); - }, + async (messageId: string, content: ContentBlock[]): Promise => { + try { + await editMessage.mutateAsync({ + messageId, + conversationId, + content: toEditableContent(content), + timestamp: Date.now(), + }); + return true; + } catch (err) { + if (err instanceof KiloChatApiError && err.status === 409) { + toast.error('Message was edited by someone else — please try again'); + return false; } - ); + toast.error(formatKiloChatError(err, 'Failed to edit message')); + return false; + } }, - [editMessage.mutate, conversationId] + [editMessage.mutateAsync, conversationId] ); const handleDelete = useCallback((messageId: string) => { @@ -269,9 +438,20 @@ export function MessageArea({ conversationId }: MessageAreaProps) { const handleExecuteAction = useCallback( (messageId: string, groupId: string, value: ExecApprovalDecision) => { + const nextPendingAction = { messageId, groupId }; + if (!tryStartPendingAction(pendingActionRef, nextPendingAction)) { + return; + } + setPendingAction(pendingActionRef.current); executeAction.mutate( { messageId, groupId, value }, - { onError: err => toast.error(formatKiloChatError(err, 'Failed to execute action')) } + { + onError: err => toast.error(formatKiloChatError(err, 'Failed to execute action')), + onSettled: () => { + clearPendingAction(pendingActionRef, nextPendingAction); + setPendingAction(pendingActionRef.current); + }, + } ); }, [executeAction.mutate] @@ -291,7 +471,7 @@ export function MessageArea({ conversationId }: MessageAreaProps) { const trimmed = renameText.trim(); if (trimmed) { renameConversation.mutate( - { conversationId, title: trimmed }, + { sandboxId, conversationId, title: trimmed }, { onError: err => toast.error(formatKiloChatError(err, 'Failed to rename conversation')) } ); } @@ -306,7 +486,7 @@ export function MessageArea({ conversationId }: MessageAreaProps) { const trimmed = renameText.trim(); if (trimmed && trimmed !== title) { renameConversation.mutate( - { conversationId, title: trimmed }, + { sandboxId, conversationId, title: trimmed }, { onError: err => toast.error(formatKiloChatError(err, 'Failed to rename conversation')) } ); } @@ -382,9 +562,11 @@ export function MessageArea({ conversationId }: MessageAreaProps) { ))} diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageBubble.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageBubble.tsx index cf24171c9d..0683c56943 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageBubble.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageBubble.tsx @@ -7,10 +7,23 @@ import { Pencil, Trash2, Reply, X, Check, AlertCircle, Smile, Copy } from 'lucid import { EmojiQuickPick } from './EmojiQuickPick'; import { EmojiPicker } from './EmojiPicker'; import { ReactionPills } from './ReactionPills'; -import type { Message, ContentBlock, ExecApprovalDecision } from '@kilocode/kilo-chat'; -import { ulidToTimestamp, contentBlocksToText } from '@kilocode/kilo-chat'; +import type { + Message, + ContentBlock, + ExecApprovalDecision, + ReplyToMessageSnapshot, +} from '@kilocode/kilo-chat'; +import { + buildMessageActionAvailability, + MESSAGE_TEXT_MAX_CHARS, + ulidToTimestamp, + contentBlocksToText, +} from '@kilocode/kilo-chat'; import { useKiloChatContext } from './kiloChatContext'; import { toast } from 'sonner'; +import { isMessageEditOverLimit, submitMessageEdit } from './message-edit-state'; + +const EDIT_COUNTER_SHOW_AT = Math.floor(MESSAGE_TEXT_MAX_CHARS * 0.8); const MemoizedMarkdown = memo(function MemoizedMarkdown({ content }: { content: string }) { return ( @@ -32,9 +45,9 @@ const MemoizedMarkdown = memo(function MemoizedMarkdown({ content }: { content: type MessageBubbleProps = { message: Message; isOwn: boolean; - replyToMessage?: Message | null; + replyToMessage?: Message | ReplyToMessageSnapshot | null; pendingDeleteId: string | null; - onEdit: (messageId: string, content: ContentBlock[]) => void; + onEdit: (messageId: string, content: ContentBlock[]) => Promise; onDelete: (messageId: string) => void; onConfirmDelete: (messageId: string) => void; onCancelDelete: () => void; @@ -42,10 +55,18 @@ type MessageBubbleProps = { onAddReaction: (messageId: string, emoji: string) => void; onRemoveReaction: (messageId: string, emoji: string) => void; onExecuteAction: (messageId: string, groupId: string, value: ExecApprovalDecision) => void; - actionPending?: boolean; - currentUserId: string; + pendingActionGroupId: string | null; + currentUserId: string | null; }; +function getReplyPreviewText(replyToMessage: Message | ReplyToMessageSnapshot): string { + const preview = + 'previewText' in replyToMessage + ? (replyToMessage.previewText ?? 'Message') + : contentBlocksToText(replyToMessage.content); + return preview.length > 60 ? `${preview.slice(0, 60)}...` : preview; +} + export const MessageBubble = memo(function MessageBubble({ message, isOwn, @@ -59,12 +80,13 @@ export const MessageBubble = memo(function MessageBubble({ onAddReaction, onRemoveReaction, onExecuteAction, - actionPending, + pendingActionGroupId, currentUserId, }: MessageBubbleProps) { const { assistantName } = useKiloChatContext(); const [isEditing, setIsEditing] = useState(false); const [editText, setEditText] = useState(''); + const [isSavingEdit, setIsSavingEdit] = useState(false); const [showActions, setShowActions] = useState(false); const [showQuickPick, setShowQuickPick] = useState(false); const [showFullPicker, setShowFullPicker] = useState(false); @@ -79,36 +101,63 @@ export const MessageBubble = memo(function MessageBubble({ }); const textContent = message.deleted ? '' : contentBlocksToText(message.content); + const editOverLimit = isMessageEditOverLimit(editText); + const showEditCounter = editText.length >= EDIT_COUNTER_SHOW_AT || editOverLimit; + const baseActionAvailability = buildMessageActionAvailability(message, isOwn); + const actionAvailability = + currentUserId === null + ? { + canReact: false, + canEdit: false, + canDelete: false, + canReply: false, + canExecuteAction: false, + } + : baseActionAvailability; const myReactions = new Set( - message.reactions.filter(r => r.memberIds.includes(currentUserId)).map(r => r.emoji) + currentUserId === null + ? [] + : message.reactions.filter(r => r.memberIds.includes(currentUserId)).map(r => r.emoji) ); function handleStartEdit() { + if (!actionAvailability.canEdit) return; setEditText(textContent); setIsEditing(true); } - function handleSaveEdit() { - const trimmed = editText.trim(); - if (!trimmed) return; - // Short-circuit no-op edits so we don't bump updatedAt and flash the - // "(edited)" label when the user presses Enter without changes. - if (trimmed === textContent.trim()) { - setIsEditing(false); - return; + const canSaveEdit = + actionAvailability.canEdit && !isSavingEdit && editText.trim().length > 0 && !editOverLimit; + + async function handleSaveEdit() { + if (!canSaveEdit) return; + setIsSavingEdit(true); + try { + await submitMessageEdit({ + messageId: message.id, + editText, + originalText: textContent, + onEdit, + closeEditor: () => { + setIsEditing(false); + setEditText(''); + }, + }); + } finally { + setIsSavingEdit(false); } - onEdit(message.id, [{ type: 'text', text: trimmed }]); - setIsEditing(false); } function handleCancelEdit() { setIsEditing(false); setEditText(''); + setIsSavingEdit(false); } function handleQuickPickSelect(emoji: string) { setShowQuickPick(false); + if (!actionAvailability.canReact) return; if (myReactions.has(emoji)) { onRemoveReaction(message.id, emoji); } else { @@ -119,6 +168,7 @@ export const MessageBubble = memo(function MessageBubble({ function handleFullPickerSelect(emoji: string) { setShowFullPicker(false); setShowQuickPick(false); + if (!actionAvailability.canReact) return; if (myReactions.has(emoji)) { onRemoveReaction(message.id, emoji); } else { @@ -134,13 +184,15 @@ export const MessageBubble = memo(function MessageBubble({ isOwn ? 'right-full mr-1' : 'left-full ml-1' }`} > - + {actionAvailability.canReact && ( + + )} - {isOwn && !message.deliveryFailed && ( + {actionAvailability.canEdit && ( )} - {isOwn && ( + {actionAvailability.canDelete && ( )} - {!message.deliveryFailed && ( + {actionAvailability.canReply && (
- {!message.deleted && !message.deliveryFailed && ( + {actionAvailability.canReact && ( void; + onSend: (text: string, inReplyToMessageId?: string) => Promise; onTyping: () => void; replyingTo: Message | null; onCancelReply: () => void; assistantName?: string; - currentUserId: string; + currentUserId: string | null; canSend?: boolean; disabledReason?: string | null; }; @@ -20,6 +20,38 @@ type MessageInputProps = { // Hide the counter until the user is at 80% capacity; below that it's noise. const COUNTER_SHOW_AT = Math.floor(MESSAGE_TEXT_MAX_CHARS * 0.8); +export function canSubmitMessageInput( + currentUserId: string | null, + canSend: boolean, + overLimit: boolean, + text: string +): boolean { + return currentUserId !== null && canSend && !overLimit && text.trim().length > 0; +} + +type MessageInputSubmissionState = { + text: string; + replyingTo: Message | null; +}; + +function sameReplyTarget(left: Message | null, right: Message | null): boolean { + return (left?.id ?? null) === (right?.id ?? null); +} + +export function nextMessageInputStateAfterSend( + currentState: MessageInputSubmissionState, + submittedState: MessageInputSubmissionState, + sendSucceeded: boolean +): MessageInputSubmissionState { + if (!sendSucceeded) return currentState; + return { + text: currentState.text === submittedState.text ? '' : currentState.text, + replyingTo: sameReplyTarget(currentState.replyingTo, submittedState.replyingTo) + ? null + : currentState.replyingTo, + }; +} + export function MessageInput({ onSend, onTyping, @@ -31,12 +63,18 @@ export function MessageInput({ disabledReason, }: MessageInputProps) { const [text, setText] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); const textareaRef = useRef(null); + const latestStateRef = useRef({ text: '', replyingTo: null }); useEffect(() => { if (replyingTo) textareaRef.current?.focus(); }, [replyingTo]); + useLayoutEffect(() => { + latestStateRef.current = { text, replyingTo }; + }, [text, replyingTo]); + useEffect(() => { const textarea = textareaRef.current; if (!textarea) return; @@ -46,26 +84,37 @@ export function MessageInput({ const overLimit = text.length > MESSAGE_TEXT_MAX_CHARS; const showCounter = text.length >= COUNTER_SHOW_AT; + const inputEnabled = currentUserId !== null && canSend; + const effectiveDisabledReason = + currentUserId === null ? 'Loading user...' : (disabledReason ?? 'Sending is disabled'); - function handleSubmit() { - if (!canSend) return; - if (overLimit) return; + async function handleSubmit() { + if (isSubmitting) return; + if (!canSubmitMessageInput(currentUserId, canSend, overLimit, text)) return; const trimmed = text.trim(); - if (!trimmed) return; - onSend(trimmed, replyingTo?.id); - setText(''); - onCancelReply(); - textareaRef.current?.focus(); + const submittedState = { text, replyingTo }; + setIsSubmitting(true); + try { + const sendSucceeded = await onSend(trimmed, replyingTo?.id); + const currentState = latestStateRef.current; + const nextState = nextMessageInputStateAfterSend(currentState, submittedState, sendSucceeded); + latestStateRef.current = nextState; + setText(nextState.text); + if (currentState.replyingTo !== null && nextState.replyingTo === null) onCancelReply(); + } finally { + setIsSubmitting(false); + textareaRef.current?.focus(); + } } function handleKeyDown(e: React.KeyboardEvent) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - handleSubmit(); + void handleSubmit(); } } - const placeholder = canSend ? 'Type a message...' : (disabledReason ?? 'Sending is disabled'); + const placeholder = inputEnabled ? 'Type a message...' : effectiveDisabledReason; return (
@@ -84,19 +133,20 @@ export function MessageInput({ placeholder={placeholder} value={text} onChange={e => { + latestStateRef.current = { ...latestStateRef.current, text: e.target.value }; setText(e.target.value); onTyping(); }} onKeyDown={handleKeyDown} rows={1} autoFocus - disabled={!canSend} + disabled={!inputEnabled} /> diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/ReactionPills.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/ReactionPills.tsx index 0573458a50..d9bca111d1 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/ReactionPills.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/ReactionPills.tsx @@ -6,7 +6,7 @@ import { EmojiPicker } from './EmojiPicker'; type ReactionPillsProps = { reactions: ReactionSummary[]; - currentUserId: string; + currentUserId: string | null; isOwn: boolean; onAdd: (emoji: string) => void; onRemove: (emoji: string) => void; @@ -26,7 +26,7 @@ export function ReactionPills({ (emoji: string) => { setShowPicker(false); const existing = reactions.find(r => r.emoji === emoji); - if (existing?.memberIds.includes(currentUserId)) { + if (currentUserId !== null && existing?.memberIds.includes(currentUserId)) { onRemove(emoji); } else { onAdd(emoji); @@ -40,7 +40,7 @@ export function ReactionPills({ return (
{reactions.map(r => { - const isMine = r.memberIds.includes(currentUserId); + const isMine = currentUserId !== null && r.memberIds.includes(currentUserId); return (