Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
3ed7b94
refactor(db): rename channel_badge_counts to badge_counts (general pu…
iscekic Apr 29, 2026
e8d062c
feat(db): migration to rename badge_counts and reset rows
iscekic Apr 29, 2026
20b9b3b
feat(notifications): add badge-bucket key builders
iscekic Apr 29, 2026
1bb97c6
chore(notifications): add EVENT_SERVICE binding, drop STREAM_CHAT_API…
iscekic Apr 29, 2026
d87c0fb
chore(notifications): add vitest scaffold
iscekic Apr 29, 2026
2a621db
feat(notifications): rewrite NotificationChannelDO around dispatchPush
iscekic Apr 29, 2026
26fccf5
chore(notifications): drop orphan badgeBucketForInstance helper
iscekic Apr 29, 2026
7fad879
feat(notifications): add sendPushForConversation WorkerEntrypoint RPC
iscekic Apr 29, 2026
f6e1848
chore(notifications): delete Stream webhook route
iscekic Apr 29, 2026
3c7c82e
chore(notifications): type EVENT_SERVICE RPC and enable cloudflare:te…
iscekic Apr 29, 2026
227b90e
feat(event-service): add kiloclaw event-context helpers; migrate kilo…
iscekic Apr 29, 2026
87f0fab
feat(kilo-chat): add fetchSandboxLabel helper
iscekic Apr 29, 2026
822d327
chore(kilo-chat): add NOTIFICATIONS service binding
iscekic Apr 29, 2026
372f0a0
feat(kilo-chat): publish push on message.created via NOTIFICATIONS RPC
iscekic Apr 29, 2026
52fe8a6
chore(notifications): drop orphan stream-chat dep, refresh worker typ…
iscekic Apr 29, 2026
4e95291
fix(notifications): named entrypoint export, retry-safe badge, alarm-…
iscekic Apr 29, 2026
4faf0dd
fix(notifications): close two cleanup-alarm leaks
iscekic Apr 29, 2026
8d7b9d7
refactor(event-service): compose presence contexts from kiloclaw helpers
iscekic Apr 29, 2026
893b7f1
feat(web): add kiloChat.getToken tRPC procedure
iscekic Apr 29, 2026
a35c98c
refactor(web): use kiloclaw-context helpers for event subscriptions
iscekic Apr 29, 2026
a43585d
feat(web): lift EventServiceClient to global provider
iscekic Apr 29, 2026
e98f370
feat(web): add usePresenceSubscription primitive
iscekic Apr 29, 2026
6bfbf95
refactor(web): collapse kilo-chat event subscriptions into usePresenc…
iscekic Apr 29, 2026
832e2b7
feat(web): subscribe to /presence/web while tab is visible
iscekic Apr 29, 2026
99b52d5
feat(web): subscribe to /presence/kiloclaw/{sandboxId} on instance views
iscekic Apr 29, 2026
bdb99c6
refactor(web): extract useDocumentVisible primitive
iscekic Apr 29, 2026
405b185
feat(web): subscribe to conversation presence while tab visible
iscekic Apr 29, 2026
4429bdf
style(web): reflow useDocumentVisible useState init to one line
iscekic Apr 29, 2026
eca983e
refactor(web): tighten presence hook + kilo-chat router contract
iscekic Apr 29, 2026
7edca1a
fix(event-service): refcount subscribe/unsubscribe by context
iscekic Apr 29, 2026
67e0fe3
chore(mobile): add EXPO_PUBLIC_KILO_CHAT_URL and EXPO_PUBLIC_EVENT_SE…
iscekic Apr 29, 2026
7b2d7fa
chore(mobile): add kilo-chat workspace deps
iscekic Apr 29, 2026
0735765
feat(mobile): add kilo-chat token getter with caching
iscekic Apr 29, 2026
4294250
feat(mobile): add useCurrentUserId from JWT sub
iscekic Apr 29, 2026
4081c29
feat(mobile): add KiloChatProvider
iscekic Apr 29, 2026
57448bd
feat(mobile): add useKiloChatClient and useEventServiceClient hooks
iscekic Apr 29, 2026
257f381
fix(mobile): fix lint errors in kilo-chat token getter
iscekic Apr 29, 2026
5844aaf
fix(mobile): fix lint errors in useCurrentUserId hook
iscekic Apr 29, 2026
825d1ac
fix(mobile): fix lint errors in useKiloChatClient hook
iscekic Apr 29, 2026
fe060dd
feat(mobile): mount KiloChatProvider in (app) layout
iscekic Apr 29, 2026
029c69a
fix(kilo-chat): assert non-null in base64urlEncode loop
iscekic Apr 29, 2026
f1eb38c
fix(mobile): share kilo-chat token cache + handle fetch errors
iscekic Apr 29, 2026
ecf29a1
fix(mobile): tie kilo-chat token cache to auth token, decode kiloUserId
iscekic Apr 30, 2026
d73befb
fix(mobile): read auth token at call time, not at hook render
iscekic Apr 30, 2026
15e595b
Merge remote-tracking branch 'origin/feat/kilo-chat-migration-pr1' in…
iscekic Apr 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
129 changes: 66 additions & 63 deletions apps/mobile/src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Stack
screenOptions={{
contentStyle: { backgroundColor: colors.background },
headerShown: false,
headerStyle: { backgroundColor: colors.background },
headerTintColor: colors.foreground,
}}
>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="chat/[instance-id]" />
<Stack.Screen
name="chat/instance-picker"
options={{
presentation: 'formSheet',
sheetAllowedDetents: [0.5, 1],
sheetGrabberVisible: true,
<KiloChatProvider>
<Stack
screenOptions={{
contentStyle: { backgroundColor: colors.background },
headerShown: false,
headerStyle: { backgroundColor: colors.background },
headerTintColor: colors.foreground,
}}
/>
<Stack.Screen name="agent-chat/new" options={{ headerShown: false }} />
<Stack.Screen name="agent-chat/[session-id]" />
<Stack.Screen
name="agent-chat/model-picker"
options={{
presentation: 'formSheet',
sheetAllowedDetents: [0.5, 1],
sheetGrabberVisible: true,
headerShown: false,
}}
/>
<Stack.Screen
name="agent-chat/repo-picker"
options={{
presentation: 'formSheet',
sheetAllowedDetents: [0.5, 1],
sheetGrabberVisible: true,
headerShown: false,
}}
/>
<Stack.Screen
name="agent-chat/mode-picker"
options={{
presentation: 'formSheet',
sheetAllowedDetents: [0.5],
sheetGrabberVisible: true,
headerShown: true,
title: 'Select Mode',
}}
/>
<Stack.Screen
name="profile"
options={{
presentation: 'modal',
headerShown: false,
}}
/>
<Stack.Screen
name="onboarding"
options={{
presentation: 'modal',
headerShown: false,
gestureEnabled: false,
}}
/>
</Stack>
>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="chat/[instance-id]" />
<Stack.Screen
name="chat/instance-picker"
options={{
presentation: 'formSheet',
sheetAllowedDetents: [0.5, 1],
sheetGrabberVisible: true,
headerShown: false,
}}
/>
<Stack.Screen name="agent-chat/new" options={{ headerShown: false }} />
<Stack.Screen name="agent-chat/[session-id]" />
<Stack.Screen
name="agent-chat/model-picker"
options={{
presentation: 'formSheet',
sheetAllowedDetents: [0.5, 1],
sheetGrabberVisible: true,
headerShown: false,
}}
/>
<Stack.Screen
name="agent-chat/repo-picker"
options={{
presentation: 'formSheet',
sheetAllowedDetents: [0.5, 1],
sheetGrabberVisible: true,
headerShown: false,
}}
/>
<Stack.Screen
name="agent-chat/mode-picker"
options={{
presentation: 'formSheet',
sheetAllowedDetents: [0.5],
sheetGrabberVisible: true,
headerShown: true,
title: 'Select Mode',
}}
/>
<Stack.Screen
name="profile"
options={{
presentation: 'modal',
headerShown: false,
}}
/>
<Stack.Screen
name="onboarding"
options={{
presentation: 'modal',
headerShown: false,
gestureEnabled: false,
}}
/>
</Stack>
</KiloChatProvider>
);
}
46 changes: 46 additions & 0 deletions apps/mobile/src/components/kilo-chat/hooks/use-current-user-id.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>(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<string, unknown>;
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;
}
20 changes: 20 additions & 0 deletions apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-client.ts
Original file line number Diff line number Diff line change
@@ -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;
}
61 changes: 61 additions & 0 deletions apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts
Original file line number Diff line number Diff line change
@@ -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<string> } | 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<string> {
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<string> {
const { token, expiresAt } = await trpcClient.kiloChat.getToken.query();
cache = { authToken, token, expiresAtMs: new Date(expiresAt).getTime() };
return token;
}
53 changes: 53 additions & 0 deletions apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx
Original file line number Diff line number Diff line change
@@ -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<KiloChatContextValue | null>(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<KiloChatContextValue>(() => {
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 <KiloChatContext.Provider value={value}>{children}</KiloChatContext.Provider>;
}
3 changes: 3 additions & 0 deletions apps/mobile/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
2 changes: 2 additions & 0 deletions apps/mobile/src/lib/env-keys.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
2 changes: 1 addition & 1 deletion packages/kilo-chat/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/=+$/, '');
}

Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.