Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 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
ae2b116
fix(mobile): open App Store Kilo Pass management
iscekic May 7, 2026
57eb120
fix(billing): expose monthly App Store Kilo Pass plans
iscekic May 7, 2026
cecd28e
fix(billing): track store Kilo Pass monthly streaks
iscekic May 7, 2026
d29cb41
fix(mobile): make Kilo Pass store sheet tappable
iscekic May 7, 2026
4431146
fix(mobile): present Kilo Pass as native modal
iscekic May 7, 2026
b418a52
docs(mobile): clarify implementation principles
iscekic May 7, 2026
b9871fa
fix(mobile): simplify Kilo Pass pricing cards
iscekic May 7, 2026
19397f6
fix(mobile): remove Kilo Pass info link
iscekic May 7, 2026
8302da3
fix(mobile): ignore App Store purchase cancellation
iscekic May 7, 2026
568a7dc
docs(mobile): require press feedback
iscekic May 7, 2026
0e13374
fix(mobile): add Kilo Pass press haptics
iscekic May 7, 2026
721a72a
fix(mobile): dismiss Kilo Pass after purchase
iscekic May 7, 2026
281bd38
fix(kilo-pass): remove yearly App Store products
iscekic May 7, 2026
e3108ad
fix(mobile): load Kilo Pass products as one query
iscekic May 7, 2026
f999170
fix(mobile): guard Kilo Pass purchase dismissal
iscekic May 7, 2026
def25f3
fix(kilo-pass): attach App Store purchases to users
iscekic May 7, 2026
6b883db
fix(mobile): keep Kilo Pass purchase on profile
iscekic May 7, 2026
f0899d8
fix(kilo-pass): handle App Store auto-renew cancellation
iscekic May 7, 2026
b9e0390
chore(web): update Apple server SDK
iscekic May 7, 2026
d6a3f63
refactor(web): centralize Apple Store SDK setup
iscekic May 7, 2026
be65b99
refactor(web): use Apple notification enums
iscekic May 7, 2026
daedd75
fix(web): ignore App Store consumption requests
iscekic May 7, 2026
a240269
fix(web): keep failed App Store renewals active
iscekic May 7, 2026
faed523
fix(kilo-pass): apply App Store upgrades
iscekic May 7, 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: 21 additions & 1 deletion apps/mobile/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,18 @@ npx expo install --dev <package-name> # devDependencies

After installing or upgrading dependencies, run `pnpx expo-doctor` and fix any issues it reports (version mismatches, duplicate deps, etc.).

## Implementation Principles

- Implement features in the simplest boring way that preserves the requested behavior. Avoid speculative abstractions, defensive layers, and "just in case" code paths.
- Keep code DRY when behavior or contracts are actually shared. Prefer one shared helper, schema, or type over duplicated local copies.
- Define shared types at the ownership boundary instead of recreating the same shape in mobile code. Use existing package exports, tRPC router output types, or a new shared contract when multiple surfaces need the same shape.
- Parse untrusted HTTP inputs with Zod at the boundary where data enters the system. After data has crossed a trusted tRPC or shared-package boundary, rely on TypeScript rather than re-parsing the same value in the mobile app.
- Do not add defense-in-depth validation inside mobile components or hooks unless the data source is genuinely untrusted or the extra check handles a real user-visible failure mode.

## Data Fetching

- When you need data from the backend, **always add a new tRPC procedure** rather than copying data or inventing client-side alternatives. The app uses tRPC with React Query — adding a procedure is cheap and keeps the source of truth on the server.
- When a component takes backend data as props, derive the prop types from the tRPC router's return types (e.g., `NonNullable<ReturnType<typeof useMyQuery>['data']>`) instead of manually copying type definitions. This keeps types in sync with the backend automatically.
- When a component takes backend data as props, derive the prop types from the tRPC router's return types (e.g., `NonNullable<ReturnType<typeof useMyQuery>['data']>`) or an existing shared type instead of manually copying type definitions. This keeps types in sync with the backend automatically.
- **Never use `new Date()` on any date or timestamp string from the backend.** Hermes cannot reliably parse PostgreSQL timestamps (`2026-03-13 14:30:00+00`) or date-only strings (`2026-09-26`). Always use `parseTimestamp()` from `@/lib/utils` — it handles both formats.

### Mutations
Expand Down Expand Up @@ -77,6 +85,18 @@ After installing or upgrading dependencies, run `pnpx expo-doctor` and fix any i

## UX Patterns

### Native Feel

- Prefer native-feeling platform experiences styled with Kilo tokens and existing app components. Start with the simplest platform-expected interaction, then apply Kilo spacing, color, type, and icon conventions.
- For platform primitives such as sheets, alerts, pickers, tabs, gestures, and keyboard behavior, use established native or app-standard components that preserve expected gestures and accessibility.
- Avoid custom replacements that need workarounds, omit standard gestures, or add complexity without a product reason. A plain sheet that behaves correctly on iOS and Android is better than a bespoke sheet that looks clever but feels broken.

### Press Feedback

- Every pressable surface must notify the user when the press gesture lands. Use the feedback that best fits the surface: pressed opacity, native ripple, haptics, state change, navigation transition, loading state, or another platform-appropriate response.
- Do not add redundant feedback. If the press immediately causes a clear visual transition or native control response, that can be enough; if the surface otherwise feels inert, add explicit pressed styling or haptics.
- Keep feedback native-feeling and lightweight. Avoid custom animations or haptic patterns that make simple list rows, cards, or buttons feel heavier than the action deserves.

### Icons

- Use `lucide-react-native` for all icons. Never use emoji as UI elements.
Expand Down
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
7 changes: 7 additions & 0 deletions apps/mobile/src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ export default function AppLayout() {
headerShown: false,
}}
/>
<Stack.Screen
name="kilo-pass"
options={{
presentation: 'modal',
headerShown: false,
}}
/>
<Stack.Screen
name="onboarding"
options={{
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/app/(app)/kilo-pass.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { KiloPassSubscriptionScreen } from '@/components/kilo-pass/kilo-pass-subscription-screen';

export default function KiloPassRoute() {
return <KiloPassSubscriptionScreen />;
}
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');
});
});
Loading