diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 3899cef255..75cea06b0a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -23,6 +23,9 @@ "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/notifications": "workspace:*", "@kilocode/trpc": "workspace:*", "@react-native-community/netinfo": "11.5.2", "@rn-primitives/portal": "^1.3.0", diff --git a/apps/mobile/src/app/(app)/_layout.tsx b/apps/mobile/src/app/(app)/_layout.tsx index 60998ebc61..d47c26095a 100644 --- a/apps/mobile/src/app/(app)/_layout.tsx +++ b/apps/mobile/src/app/(app)/_layout.tsx @@ -1,75 +1,78 @@ import { Stack } from 'expo-router'; +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/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..6c1e850f4c --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-current-user-id.ts @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react'; + +import { useKiloChatTokenGetter } from './use-kilo-chat-token'; + +/** + * Decodes the `kiloUserId` claim from the kilo-chat JWT and returns it as the + * current user's ID. Returns `null` while loading or if the token cannot be + * decoded. The token is minted by `generateApiToken`, which writes the user id + * as `kiloUserId` (not the standard JWT `sub` claim). + */ +export function useCurrentUserId(): string | null { + const getToken = useKiloChatTokenGetter(); + const [userId, setUserId] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function fetchUserId() { + try { + const token = await getToken(); + if (cancelled) { + return; + } + const parts = token.split('.'); + if (parts.length < 2 || !parts[1]) { + return; + } + const payload = parts[1]; + const decoded = atob(payload.replaceAll('-', '+').replaceAll('_', '/')); + const parsed = JSON.parse(decoded) as Record; + const kiloUserId = typeof parsed.kiloUserId === 'string' ? parsed.kiloUserId : null; + setUserId(kiloUserId); + } catch { + // Leave userId as null on failure + } + } + + void fetchUserId(); + + return () => { + cancelled = true; + }; + }, [getToken]); + + return userId; +} 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..32e7402aa3 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-client.ts @@ -0,0 +1,20 @@ +import { type EventServiceClient } from '@kilocode/event-service'; +import { type KiloChatClient } from '@kilocode/kilo-chat'; + +import { useKiloChatContext } from '../kilo-chat-provider'; + +/** + * Returns the {@link KiloChatClient} instance from the nearest + * {@link KiloChatProvider}. Throws if called outside a provider. + */ +export function useKiloChatClient(): KiloChatClient { + return useKiloChatContext().kiloChatClient; +} + +/** + * Returns the {@link EventServiceClient} instance from the nearest + * {@link KiloChatProvider}. Throws if called outside a provider. + */ +export function useEventServiceClient(): EventServiceClient { + return useKiloChatContext().eventService; +} 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..bec3cbaef0 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts @@ -0,0 +1,61 @@ +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 TokenCache = { + authToken: string; + token: string; + expiresAtMs: number; +}; + +// 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; + +/** + * 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 { + 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.token; + } + + 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 { token, expiresAt } = await trpcClient.kiloChat.getToken.query(); + cache = { authToken, token, expiresAtMs: new Date(expiresAt).getTime() }; + return token; +} 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..f255f96a2b --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx @@ -0,0 +1,53 @@ +import { createContext, useContext, useEffect, useState } from 'react'; + +import { EventServiceClient } from '@kilocode/event-service'; +import { KiloChatClient } from '@kilocode/kilo-chat'; + +import { EVENT_SERVICE_URL, KILO_CHAT_URL } from '@/lib/config'; + +import { useKiloChatTokenGetter } from './hooks/use-kilo-chat-token'; + +type KiloChatContextValue = { + eventService: EventServiceClient; + kiloChatClient: KiloChatClient; +}; + +export const KiloChatContext = createContext(null); + +export function useKiloChatContext(): KiloChatContextValue { + const ctx = useContext(KiloChatContext); + if (!ctx) { + throw new Error('useKiloChatContext must be used within a KiloChatProvider'); + } + return ctx; +} + +type KiloChatProviderProps = { + children: React.ReactNode; +}; + +export function KiloChatProvider({ children }: KiloChatProviderProps) { + const getToken = useKiloChatTokenGetter(); + + const [value] = useState(() => { + const eventService = new EventServiceClient({ + url: EVENT_SERVICE_URL, + getToken, + }); + const kiloChatClient = new KiloChatClient({ + eventService, + baseUrl: KILO_CHAT_URL, + getToken, + }); + return { eventService, kiloChatClient }; + }); + + useEffect(() => { + void value.eventService.connect(); + return () => { + value.eventService.disconnect(); + }; + }, [value]); + + return {children}; +} diff --git a/apps/mobile/src/lib/config.ts b/apps/mobile/src/lib/config.ts index b90c113990..be543127f8 100644 --- a/apps/mobile/src/lib/config.ts +++ b/apps/mobile/src/lib/config.ts @@ -18,3 +18,6 @@ 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'); diff --git a/apps/mobile/src/lib/env-keys.js b/apps/mobile/src/lib/env-keys.js index 3ec4d72001..3200f2ecda 100644 --- a/apps/mobile/src/lib/env-keys.js +++ b/apps/mobile/src/lib/env-keys.js @@ -7,4 +7,6 @@ export const ENV_KEYS = { sessionIngestWsUrl: 'SESSION_INGEST_WS_URL', appsFlyerDevKey: 'APPSFLYER_DEV_KEY', appsFlyerAppId: 'APPSFLYER_APP_ID', + kiloChatUrl: 'EXPO_PUBLIC_KILO_CHAT_URL', + eventServiceUrl: 'EXPO_PUBLIC_EVENT_SERVICE_URL', }; diff --git a/packages/kilo-chat/src/utils.ts b/packages/kilo-chat/src/utils.ts index 741140875d..56f794469d 100644 --- a/packages/kilo-chat/src/utils.ts +++ b/packages/kilo-chat/src/utils.ts @@ -18,7 +18,7 @@ export type ConversationCursor = { t: number; c: string }; function base64urlEncode(bytes: Uint8Array): string { let binary = ''; - for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ea3a33947..3a7e7b96b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,15 @@ importers: '@expo/react-native-action-sheet': specifier: ^4.1.1 version: 4.1.1(@types/react@19.2.14)(react@19.2.0) + '@kilocode/event-service': + specifier: workspace:* + version: link:../../packages/event-service + '@kilocode/kilo-chat': + specifier: workspace:* + version: link:../../packages/kilo-chat + '@kilocode/notifications': + specifier: workspace:* + version: link:../../packages/notifications '@kilocode/trpc': specifier: workspace:* version: link:../../packages/trpc