Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
ec19041
docs(billing): specify Apple credit purchases
iscekic May 6, 2026
348bf02
feat(db): add Apple IAP purchase tables
iscekic May 6, 2026
724094f
feat(web): define Apple credit products
iscekic May 6, 2026
cd05810
feat(web): add Apple signed data verifier
iscekic May 6, 2026
f0681cb
feat(web): process Apple credit purchases
iscekic May 6, 2026
f877f8f
feat(web): expose Apple credit purchase APIs
iscekic May 6, 2026
ca2c87b
feat(web): handle Apple IAP notifications
iscekic May 6, 2026
00f815e
feat(mobile): add Apple credit purchase hooks
iscekic May 6, 2026
e3b14c3
feat(mobile): add Apple credit purchase UI
iscekic May 6, 2026
34ca77a
fix(mobile): stabilize Apple credit purchase hooks
iscekic May 6, 2026
72128b2
docs(mobile): document Apple credit purchase setup
iscekic May 6, 2026
471bbc0
fix: stabilize Apple credit purchase verification
iscekic May 6, 2026
d658a90
fix: address Apple IAP review feedback
iscekic May 6, 2026
1a57af8
fix(mobile): remove home welcome headline
iscekic May 6, 2026
1ef7981
fix(mobile): remove kilo chat debug logs
iscekic May 6, 2026
1124806
fix(mobile): enable expo iap config plugin
iscekic May 6, 2026
147dc4c
fix(mobile): handle apple purchases from storekit events
iscekic May 6, 2026
a140354
chore(mobile): align expo sdk dependencies
iscekic May 6, 2026
5070823
fix(mobile): recover unfinished apple credit purchases
iscekic May 6, 2026
0fc605a
fix(kilo-pass): remove one-time apple credit purchases
iscekic May 6, 2026
18aaf73
feat(kilo-pass): add app store subscriptions
iscekic May 6, 2026
8d3fa34
fix(kilo-pass): order app store subscriptions
iscekic May 6, 2026
33f1fa5
fix(mobile): make Kilo Pass purchase sheet native
iscekic May 6, 2026
b77479a
fix(mobile): remove web parity Kilo Pass copy
iscekic May 6, 2026
a3438c4
fix(mobile): add Kilo Pass explainer link
iscekic May 6, 2026
4b8c7dc
fix(mobile): update Kilo Pass teaser copy
iscekic May 6, 2026
5b2a063
fix(mobile): route web Kilo Pass management
iscekic May 6, 2026
39a64b3
fix(mobile): recover Kilo Pass store purchases
iscekic May 6, 2026
88d0a49
fix(mobile): sign out on unauthorized tRPC queries
iscekic May 6, 2026
0c5228a
fix(mobile): move KiloClaw skeleton to top
iscekic May 6, 2026
8806841
fix(mobile): add agent empty state CTA
iscekic May 6, 2026
5dafc8b
fix(kilo-pass): make store completion idempotent
iscekic May 6, 2026
96cd281
fix(web): show account on device authorization
iscekic May 6, 2026
9c418fa
docs: revert KiloClaw billing spec change
iscekic May 6, 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
22 changes: 22 additions & 0 deletions apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,25 @@ Generally speaking, you only need a new dev build if making dependency/native ch
3. create a new dev build using `pnpm build:ios`
4. `pnpm start`
5. open installed app on your phone

## Apple In-App Credit Purchases

iOS credit purchases require an EAS development build or TestFlight build with
the in-app purchase capability enabled. Expo Go is not supported for this
feature.

Configured consumable product IDs:

- `com.kilocode.kiloapp.credits.small.999`
- `com.kilocode.kiloapp.credits.medium.1999`
- `com.kilocode.kiloapp.credits.large.4999`

Use App Store Connect sandbox tester accounts for local and TestFlight sandbox
verification. Configure App Store Server Notifications V2 to post to
`/api/apple/iap/notifications`.

Backend environment variables:

- `APPLE_IAP_ENVIRONMENT`
- `APPLE_APP_APPLE_ID`
- `APPLE_ROOT_CERTIFICATES_PEM`
1 change: 1 addition & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ const config: ExpoConfig = {
],
'expo-apple-authentication',
'expo-audio',
'expo-iap',
'expo-sharing',
'expo-video',
'expo-asset',
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const cloudAgentSdkPath = path.resolve(webSrc, 'lib', 'cloud-agent-sdk');
const config = getSentryExpoConfig(__dirname);

// Allow Metro to resolve workspace files and pnpm's real package paths
config.watchFolders = [monorepoRoot];
config.watchFolders = [...new Set([...(config.watchFolders || []), monorepoRoot])];

// Let SDK dependencies (jotai, zod, etc.) resolve from the monorepo root node_modules
config.resolver.nodeModulesPaths = [
Expand Down
69 changes: 39 additions & 30 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,47 +31,48 @@
"@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",
"@sentry/react-native": "~8.10.0",
"@shopify/flash-list": "2.0.2",
"@tailwindcss/postcss": "^4.2.2",
"@tanstack/react-query": "catalog:",
"@trpc/client": "catalog:",
"@trpc/tanstack-react-query": "catalog:",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"expo": "~55.0.12",
"expo-apple-authentication": "~55.0.12",
"expo-application": "~55.0.13",
"expo-asset": "~55.0.13",
"expo-audio": "~55.0.12",
"expo": "~55.0.23",
"expo-apple-authentication": "~55.0.13",
"expo-application": "~55.0.14",
"expo-asset": "~55.0.17",
"expo-audio": "~55.0.14",
"expo-blur": "~55.0.14",
"expo-build-properties": "~55.0.12",
"expo-clipboard": "~55.0.12",
"expo-constants": "~55.0.12",
"expo-build-properties": "~55.0.13",
"expo-clipboard": "~55.0.13",
"expo-constants": "~55.0.16",
"expo-crypto": "~55.0.14",
"expo-dev-client": "~55.0.23",
"expo-document-picker": "~55.0.12",
"expo-font": "~55.0.6",
"expo-haptics": "~55.0.13",
"expo-image": "~55.0.8",
"expo-image-picker": "~55.0.17",
"expo-insights": "55.0.15",
"expo-linking": "~55.0.11",
"expo-location": "~55.1.8",
"expo-notifications": "~55.0.17",
"expo-router": "~55.0.11",
"expo-secure-store": "~55.0.12",
"expo-sharing": "~55.0.17",
"expo-splash-screen": "~55.0.16",
"expo-status-bar": "~55.0.5",
"expo-tracking-transparency": "~55.0.12",
"expo-video": "~55.0.14",
"expo-web-browser": "~55.0.13",
"expo-dev-client": "~55.0.32",
"expo-document-picker": "~55.0.13",
"expo-font": "~55.0.7",
"expo-haptics": "~55.0.14",
"expo-iap": "^4.2.4",
"expo-image": "~55.0.10",
"expo-image-picker": "~55.0.20",
"expo-insights": "55.0.16",
"expo-linking": "~55.0.15",
"expo-location": "~55.1.9",
"expo-notifications": "~55.0.22",
"expo-router": "~55.0.14",
"expo-secure-store": "~55.0.13",
"expo-sharing": "~55.0.18",
"expo-splash-screen": "~55.0.20",
"expo-status-bar": "~55.0.6",
"expo-tracking-transparency": "~55.0.13",
"expo-video": "~55.0.16",
"expo-web-browser": "~55.0.15",
"jotai": "^2.18.1",
"lucide-react-native": "^1.7.0",
"nativewind": "5.0.0-preview.3",
"react": "19.2.0",
"react-native": "0.83.4",
"react-native": "0.83.6",
"react-native-appsflyer": "^6.17.9",
"react-native-css": "3.0.6",
"react-native-gesture-handler": "~2.30.0",
Expand All @@ -80,11 +81,12 @@
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0",
"react-native-svg": "15.15.3",
"react-native-worklets": "0.7.2",
"react-native-worklets": "0.7.4",
"sonner-native": "^0.23.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"ulid": "3.0.1"
"ulid": "3.0.1",
"zod": "catalog:"
},
"devDependencies": {
"@sentry/cli": "catalog:",
Expand All @@ -100,5 +102,12 @@
"injected": true
}
},
"expo": {
"install": {
"exclude": [
"@sentry/react-native"
]
}
},
"private": true
}
18 changes: 14 additions & 4 deletions apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { type Href, useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import { View } from 'react-native';
import { Platform, View } from 'react-native';
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

import { EmptyStateContent } from '@/components/kiloclaw/empty-state-content';
import { getKiloClawEntryDecision } from '@/components/kiloclaw/instance-entry-state';
Expand All @@ -16,10 +17,12 @@ import { useKiloClawMobileOnboardingState } from '@/lib/hooks/use-kiloclaw-queri
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { useUnreadCounts } from '@/lib/hooks/use-unread-counts';
import { chatSandboxPath } from '@/lib/kilo-chat-routes';
import { getTabBarOverlayHeight } from '@/lib/tab-bar-layout';

export default function KiloClawTab() {
const router = useRouter();
const colors = useThemeColors();
const { bottom } = useSafeAreaInsets();
const [manualRefreshing, setManualRefreshing] = useState(false);
const instancesQuery = useAllKiloClawInstances();
const { data: instances } = instancesQuery;
Expand All @@ -30,6 +33,9 @@ export default function KiloClawTab() {
useForegroundInvalidateKiloclawState();

const showInstanceSkeleton = entryDecision.kind === 'loading' || onboardingQuery.isPending;
const emptyStateContainerStyle = {
paddingBottom: getTabBarOverlayHeight(bottom, Platform.OS),
};

const handleRefresh = useCallback(() => {
void (async () => {
Expand Down Expand Up @@ -94,15 +100,19 @@ export default function KiloClawTab() {
className="px-[22px]"
headerRight={<ProfileAvatarButton />}
/>
<Animated.View layout={LinearTransition} className="flex-1 items-center justify-center px-4">
<Animated.View layout={LinearTransition} className="flex-1 px-4">
{showInstanceSkeleton ? (
<Animated.View exiting={FadeOut.duration(150)} className="w-full gap-3 px-4">
<Animated.View exiting={FadeOut.duration(150)} className="w-full gap-3 px-4 pt-5">
<Skeleton className="h-16 w-full rounded-xl" />
<Skeleton className="h-16 w-full rounded-xl" />
<Skeleton className="h-16 w-full rounded-xl" />
</Animated.View>
) : (
<Animated.View entering={FadeIn.duration(200)}>
<Animated.View
entering={FadeIn.duration(200)}
className="flex-1 items-center justify-center"
style={emptyStateContainerStyle}
>
<EmptyStateContent
foregroundColor={colors.foreground}
state={onboardingQuery.data}
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/app/(app)/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';

import { BlurBar } from '@/components/ui/blur-bar';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { ANDROID_TAB_BAR_EXTRA_PADDING, TAB_BAR_BASE_HEIGHT } from '@/lib/tab-bar-layout';

const ANDROID_TAB_BAR_EXTRA_PADDING = 4;
const TAB_BAR_ITEM_CONTENT_WIDTH = 64;
const TAB_BAR_ICON_STYLE = {
alignItems: 'center',
Expand Down Expand Up @@ -64,7 +64,7 @@ export default function TabsLayout() {
elevation: 0,
position: 'absolute',
...(Platform.OS === 'android' && {
height: 50 + bottom + ANDROID_TAB_BAR_EXTRA_PADDING,
height: TAB_BAR_BASE_HEIGHT + bottom + ANDROID_TAB_BAR_EXTRA_PADDING,
}),
},
}}
Expand Down
38 changes: 32 additions & 6 deletions apps/mobile/src/components/agents/session-list-content.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { Bot, Search } from 'lucide-react-native';
import { Bot, Plus, Search } from 'lucide-react-native';
import { useCallback, useMemo } from 'react';
import { RefreshControl, SectionList, TextInput, View } from 'react-native';
import { Platform, RefreshControl, SectionList, TextInput, View } from 'react-native';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

import { type SessionItem, type SessionSection } from '@/components/agents/session-list-helpers';
import { RemoteSessionRow, StoredSessionRow } from '@/components/agents/session-row';
import { EmptyState } from '@/components/empty-state';
import { QueryError } from '@/components/query-error';
import { Button } from '@/components/ui/button';
import { Eyebrow } from '@/components/ui/eyebrow';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
import { type StoredSession } from '@/lib/hooks/use-agent-sessions';
import { useSessionMutations } from '@/lib/hooks/use-session-mutations';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { getTabBarOverlayHeight } from '@/lib/tab-bar-layout';

// Height of the hidden-by-default search bar (mt-3 12 + border 1 + py-14 28 + line-20 + border 1 + mb-14 14 = 76).
const SEARCH_BAR_HEIGHT = 76;
Expand All @@ -26,6 +29,7 @@ type AgentSessionListContentProps = {
refetch: () => Promise<void>;
onSessionPress: (sessionId: string, organizationId?: string | null) => void;
onSearchChange: (text: string) => void;
onCreateSession: () => void;
};

export function AgentSessionListContent({
Expand All @@ -37,9 +41,15 @@ export function AgentSessionListContent({
refetch,
onSessionPress,
onSearchChange,
onCreateSession,
}: Readonly<AgentSessionListContentProps>) {
const colors = useThemeColors();
const { bottom } = useSafeAreaInsets();
const { deleteSession, renameSession } = useSessionMutations();
const emptyStateContainerStyle = useMemo(
() => ({ paddingBottom: getTabBarOverlayHeight(bottom, Platform.OS) }),
[bottom]
);

const listHeader = useMemo(
() => (
Expand All @@ -59,17 +69,28 @@ export function AgentSessionListContent({
[colors.mutedForeground, onSearchChange]
);

const emptyStateAction = useMemo(
() => (
<Button variant="outline" onPress={onCreateSession}>
<Plus size={16} color={colors.foreground} />
<Text>New coding task</Text>
</Button>
),
[colors.foreground, onCreateSession]
);

const listEmptyComponent = useMemo(
() => (
<View className="items-center justify-center pt-16">
<EmptyState
icon={Bot}
title="No sessions yet"
description="Your agent sessions will appear here"
description="Start a coding task from your phone. Your sessions will appear here."
action={emptyStateAction}
/>
</View>
),
[]
[emptyStateAction]
);

const organizationIdBySessionId = useMemo(
Expand Down Expand Up @@ -154,11 +175,16 @@ export function AgentSessionListContent({
// list with only a ListEmptyComponent would leave the search bar fully visible.
if (!hasAnySessions) {
return (
<Animated.View entering={FadeIn.duration(200)} className="flex-1 items-center justify-center">
<Animated.View
entering={FadeIn.duration(200)}
className="flex-1 items-center justify-center"
style={emptyStateContainerStyle}
>
<EmptyState
icon={Bot}
title="No sessions yet"
description="Your agent sessions will appear here"
description="Start a coding task from your phone. Your sessions will appear here."
action={emptyStateAction}
/>
</Animated.View>
);
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/components/agents/session-list-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function getNewAgentSessionPath(organizationId: string | null): string {
return organizationId
? `/(app)/agent-chat/new?organizationId=${organizationId}`
: '/(app)/agent-chat/new';
}
13 changes: 13 additions & 0 deletions apps/mobile/src/components/agents/session-list-screen.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';

import { getNewAgentSessionPath } from '@/components/agents/session-list-routes';

describe('getNewAgentSessionPath', () => {
it('routes personal sessions to the new agent screen', () => {
expect(getNewAgentSessionPath(null)).toBe('/(app)/agent-chat/new');
});

it('preserves the organization context', () => {
expect(getNewAgentSessionPath('org_123')).toBe('/(app)/agent-chat/new?organizationId=org_123');
});
});
9 changes: 5 additions & 4 deletions apps/mobile/src/components/agents/session-list-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Pressable, View } from 'react-native';
import Animated, { LinearTransition } from 'react-native-reanimated';

import { getNewAgentSessionPath } from '@/components/agents/session-list-routes';
import { AgentSessionListContent } from '@/components/agents/session-list-content';
import {
type ProjectFilterOption,
Expand Down Expand Up @@ -202,10 +203,7 @@ export function AgentSessionListScreen() {
<View className="flex-row items-center gap-4">
<Pressable
onPress={() => {
const path = organizationId
? `/(app)/agent-chat/new?organizationId=${organizationId}`
: '/(app)/agent-chat/new';
router.push(path as Href);
router.push(getNewAgentSessionPath(organizationId) as Href);
}}
hitSlop={8}
accessibilityLabel="New session"
Expand Down Expand Up @@ -251,6 +249,9 @@ export function AgentSessionListScreen() {
refetch={refetch}
onSessionPress={navigateToSession}
onSearchChange={handleSearchChange}
onCreateSession={() => {
router.push(getNewAgentSessionPath(organizationId) as Href);
}}
/>
</Animated.View>
{showFilterModal && (
Expand Down
Loading