Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
b246a13
feat(mobile): fence auth and metadata persistence
iscekic Aug 5, 2026
1a492fd
feat(web): add device session procedures
iscekic Aug 5, 2026
1bc1fbb
feat(mobile): add device sessions screen
iscekic Aug 6, 2026
27edea3
feat(mobile): add encrypted sqlcipher kv store
iscekic Aug 6, 2026
80765aa
feat(mobile): persist encrypted read cache
iscekic Aug 6, 2026
d58e51d
fix(mobile): fence read cache during auth teardown
iscekic Aug 6, 2026
fb9e844
feat(mobile): persist durable composer drafts
iscekic Aug 6, 2026
2264cca
fix(mobile): reconcile logout cleanup safely
iscekic Aug 6, 2026
2134bfb
fix(mobile): retain provider resources reversibly
iscekic Aug 6, 2026
c2460d0
fix(mobile): serialize persistent preference writes
iscekic Aug 6, 2026
7b80e1f
fix(mobile): fence teardown races in persistence
iscekic Aug 6, 2026
084d0d4
fix(mobile): prove SQLCipher before opening the persist database
iscekic Aug 6, 2026
59f0294
fix(mobile): stop retrying a failed persist database open
iscekic Aug 6, 2026
47652de
refactor(mobile): break the auth-context require cycles
iscekic Aug 6, 2026
826d7a6
refactor(mobile): drop the owned-resource hook
iscekic Aug 6, 2026
9d064bb
refactor(mobile): stop retrying the device session revoke on logout
iscekic Aug 6, 2026
7404413
refactor(mobile): drop the token owner generation counter
iscekic Aug 6, 2026
007f948
refactor(mobile): inline the single-use new-session draft helpers
iscekic Aug 6, 2026
74b7c36
refactor(mobile): drop the type-duplicate half of the kv validators
iscekic Aug 6, 2026
89e1105
refactor(mobile): inline the single-use device session strings
iscekic Aug 6, 2026
73a3e16
refactor(web): flatten the revoke device session outcome union
iscekic Aug 6, 2026
c638663
Merge remote-tracking branch 'origin/main' into audit-w3a-account-per…
iscekic Aug 7, 2026
5b3cda7
Merge origin/main into audit-w3a-account-persistence-089a
iscekic Aug 17, 2026
f9d2f92
fix(mobile): resolve lint failures
iscekic Aug 17, 2026
86ea9fb
refactor(mobile): simplify account persistence internals
iscekic Aug 17, 2026
97cc57e
feat(mobile): run expo-sqlite through drizzle
iscekic Aug 17, 2026
8b7e9ec
fix(mobile): clear every cache scope on sign-out
iscekic Aug 17, 2026
ccaa324
fix(mobile): keep the session when startup rotates the token
iscekic Aug 17, 2026
7c07ebc
fix(mobile): let the user type before identity resolves
iscekic Aug 17, 2026
0934214
refactor(web): collapse the device session revoke outcomes
iscekic Aug 17, 2026
c8935f1
refactor(mobile): keep one sign-out-in-progress flag
iscekic Aug 17, 2026
98b1718
chore: drop the expo release-age exclusions
iscekic Aug 17, 2026
8f19984
fix(mobile): report every encrypted-kv recovery failure
iscekic Aug 17, 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
4 changes: 4 additions & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ const config: ExpoConfig = {
'expo-font',
'expo-secure-store',
'expo-sharing',
// Encrypts the local persistence database (kilo-persist.db) with SQLCipher.
// The key is generated from expo-crypto and held in SecureStore; see
// src/lib/persist/encrypted-kv.ts.
['expo-sqlite', { useSQLCipher: true }],
[
'expo-notifications',
{
Expand Down
13 changes: 8 additions & 5 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,20 @@
"@sentry/react-native": "~7.11.0",
"@shopify/flash-list": "2.0.2",
"@tailwindcss/postcss": "4.3.3",
"@tanstack/query-async-storage-persister": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-persist-client": "catalog:",
"@trpc/client": "catalog:",
"@trpc/tanstack-react-query": "catalog:",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"expo": "~57.0.9",
"expo": "~57.0.10",
"expo-apple-authentication": "~57.0.1",
"expo-application": "~57.0.2",
"expo-blur": "~57.0.2",
"expo-build-properties": "~57.0.8",
"expo-clipboard": "~57.0.1",
"expo-constants": "~57.0.8",
"expo-constants": "~57.0.9",
"expo-crypto": "~57.0.1",
"expo-dev-client": "~57.0.10",
"expo-device": "~57.0.1",
Expand All @@ -58,21 +60,22 @@
"expo-font": "57.0.1",
"expo-haptics": "~57.0.1",
"expo-iap": "4.7.0",
"expo-image": "57.0.1",
"expo-image": "57.0.2",
"expo-image-picker": "~57.0.7",
"expo-keep-awake": "~57.0.1",
"expo-linear-gradient": "~57.0.1",
"expo-linking": "57.0.4",
"expo-linking": "57.0.5",
"expo-localization": "~57.0.1",
"expo-location": "~57.0.7",
"expo-notifications": "~57.0.8",
"expo-router": "~57.0.9",
"expo-router": "~57.0.10",
"expo-screen-corner-radius": "1.1.0",
"expo-secure-store": "~57.0.1",
"expo-share-intent": "8.0.1",
"expo-sharing": "~57.0.8",
"expo-speech-recognition": "56.0.1",
"expo-splash-screen": "~57.0.5",
"expo-sqlite": "~57.0.1",
"expo-status-bar": "57.0.1",
"expo-store-review": "~57.0.1",
"expo-tracking-transparency": "~57.0.1",
Expand Down
38 changes: 38 additions & 0 deletions apps/mobile/src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,49 @@
import { Stack } from 'expo-router';
import { useEffect } from 'react';
import { AppState } from 'react-native';

import { UserWebConnectionProvider } from '@/components/agents/user-web-connection-provider';
import { KiloChatPresenceMount } from '@/components/kilo-chat/kilo-chat-presence-mount';
import { KiloChatProvider } from '@/components/kilo-chat/kilo-chat-provider';
import { SharePayloadNavigator } from '@/components/share/share-payload-navigator';
import { ActiveSessionsLiveSyncMount } from '@/lib/active-sessions-live-sync-mount';
import { attemptLogoutReconciliation } from '@/lib/auth/logout-reconciliation';
import { useFormSheetDetents } from '@/lib/form-sheet';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { useCurrentUserId } from '@/lib/hooks/use-current-user-id';
import { StoreKiloPassPurchaseProvider } from '@/lib/kilo-pass/use-store-kilo-pass-purchase';
import { CachePersistenceMount } from '@/lib/persist/cache-persistence-mount';

/**
* Attempts failed logout cleanup on every "next authenticated opportunity":
* once `user.getMe` has resolved on the authenticated mount, and on each
* AppState return to `active` while authenticated. The attempt itself is
* single-flight with 60 s spacing, so foreground flaps do not hammer the
* server and a transient failure retries on the next foreground.
*/
function LogoutReconciliationMount() {
const { userId, isLoading, isError } = useCurrentUserId();

useEffect(() => {
if (!userId || isLoading || isError) {
return undefined;
}

void attemptLogoutReconciliation(userId);

const subscription = AppState.addEventListener('change', nextState => {
if (nextState === 'active') {
void attemptLogoutReconciliation(userId);
}
});

return () => {
subscription.remove();
};
}, [userId, isLoading, isError]);

return null;
}

export default function AppLayout() {
const colors = useThemeColors();
Expand All @@ -16,6 +52,8 @@ export default function AppLayout() {
return (
<UserWebConnectionProvider>
<ActiveSessionsLiveSyncMount />
<CachePersistenceMount />
<LogoutReconciliationMount />
<SharePayloadNavigator />
<KiloChatProvider>
<KiloChatPresenceMount>
Expand Down
182 changes: 140 additions & 42 deletions apps/mobile/src/app/(app)/agent-chat/new.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { View } from 'react-native';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AppState, View } from 'react-native';
import { useLocalSearchParams } from 'expo-router';
import { useActionSheet } from '@expo/react-native-action-sheet';
import { useQuery } from '@tanstack/react-query';

import { NewSessionConfigureForm } from '@/components/agents/new-session-configure-form';
import { useNewSessionCreator } from '@/components/agents/use-new-session-creator';
import {
resolveRestoredNewSessionPrompt,
useNewSessionCreator,
useNewSessionDraft,
useRemoteSpawnDraftCleanup,
} from '@/components/agents/use-new-session-creator';
import {
NewSessionModelProvider,
useNewSessionModelState,
Expand All @@ -16,11 +21,20 @@ import { ScreenHeader } from '@/components/screen-header';
import { AGENT_ATTACHMENT_MAX_FILES } from '@/lib/agent-attachments/constants';
import { useAgentAttachmentUpload } from '@/lib/agent-attachments/use-agent-attachment-upload';
import { useAvailableModels } from '@/lib/hooks/use-available-models';
import { useCurrentUserId } from '@/lib/hooks/use-current-user-id';
import { useModelPreferences } from '@/lib/hooks/use-model-preferences';
import { usePersistedAgentModel } from '@/lib/hooks/use-persisted-agent-model';
import { resolveNewSessionSubmitDisabled } from '@/lib/new-session-submit';
import {
clearDraft,
flushDraft,
NEW_SESSION_DRAFT_KEY,
resolvePrefillOverDraft,
saveDraft,
} from '@/lib/persist/drafts';
import { type InstancePickerInstance } from '@/lib/picker-bridge';
import { shouldShowRunOnSelector } from '@/lib/should-show-run-on-selector';
import { peekSharePayload } from '@/lib/share-payload';
import { useNewSessionShareRemote } from '@/lib/use-new-session-share-remote';
import { useNewSessionRepos } from '@/lib/use-new-session-repos';
import { useTRPC } from '@/lib/trpc';
Expand Down Expand Up @@ -54,6 +68,42 @@ function NewSessionScreenBody() {

const showRunOnSelector = shouldShowRunOnSelector(organizationId);

// Durable new-session draft. The prompt's `initialPrompt` seeds the
// uncontrolled input once, so the route resolves the stored draft (and the
// share prefill) BEFORE mounting the form: a draft must never render first
// and then be replaced by a late prefill. `undefined` marks the not-settled
// state so the form stays on the pre-render state while the local load runs.
const { userId, isLoading: isIdentityLoading } = useCurrentUserId();
const draftState = useNewSessionDraft({ userId, isIdentityLoading });

// Share-prefill precedence, resolved once before the prompt mounts. The
// prompt's own `useSharePrefill` still delivers the shared files and
// re-applies the same text idempotently.
const [sharePrefillText] = useState<string | null>(() =>
shareId ? (peekSharePayload(shareId)?.text ?? null) : null
);
const initialPrompt = draftState.settled
? resolvePrefillOverDraft(sharePrefillText, draftState.text)
: undefined;

// Save the new-session draft debounced on every text change, and flush the
// pending write when the app leaves `active`. The draft's fate on unmount —
// flush to preserve, or clear after a consumed remote spawn — is owned by
// `useRemoteSpawnDraftCleanup`.
useEffect(() => {
if (!userId) {
return undefined;
}
const subscription = AppState.addEventListener('change', nextAppState => {
if (nextAppState !== 'active') {
void flushDraft(userId, NEW_SESSION_DRAFT_KEY);
}
});
return () => {
subscription.remove();
};
}, [userId]);

const {
models,
isLoading: isLoadingModels,
Expand Down Expand Up @@ -97,16 +147,50 @@ function NewSessionScreenBody() {
[instancesData]
);

// A successful session creation owns clearing the new-session draft; a
// failure must preserve it for the retry.
const handleCreated = useCallback(() => {
if (userId) {
void clearDraft(userId, NEW_SESSION_DRAFT_KEY);
}
}, [userId]);

// Remote spawn success lives inside the spawn dispatch (it replaces the
// screen); the route records the attempt here and clears the consumed draft
// when the screen unmounts — a failed spawn keeps the screen mounted.
const { markRemoteSpawnAttempted } = useRemoteSpawnDraftCleanup({ userId });

const { createSessionFromDraft, promptRef } = useNewSessionCreator({
attachments,
mode,
model,
organizationId,
onCreated: handleCreated,
selectedRepo,
setIsCreating,
variant,
});

// Seed the route-owned prompt state from the restored draft once the load
// settles: the prompt input notifies the route only on typing, so without
// this the creator's `promptRef` stays empty and the Start button stays
// disabled (and a remote start would send an empty prompt). Re-armed
// whenever the draft leaves the settled state (e.g. an account switch).
const promptStateSeededRef = useRef(false);
useEffect(() => {
if (!draftState.settled) {
promptStateSeededRef.current = false;
return;
}
if (promptStateSeededRef.current) {
return;
}
promptStateSeededRef.current = true;
const restored = resolveRestoredNewSessionPrompt(initialPrompt);
promptRef.current = restored.prompt;
setHasPrompt(restored.hasPrompt);
}, [draftState.settled, initialPrompt, promptRef]);

const { remoteSpawn, handleRunOnInstanceChange } = useNewSessionShareRemote({
organizationId,
runOnInstance,
Expand All @@ -131,6 +215,9 @@ function NewSessionScreenBody() {
promptRef.current = text;
const nextHasPrompt = text.trim().length > 0;
setHasPrompt(current => (current === nextHasPrompt ? current : nextHasPrompt));
if (userId) {
saveDraft(userId, NEW_SESSION_DRAFT_KEY, text);
}
}

const submitWithVoiceSettled = useCallback(async (submit: () => Promise<void>) => {
Expand Down Expand Up @@ -184,56 +271,67 @@ function NewSessionScreenBody() {

const handleStartSession = useCallback(() => {
if (runOnInstance !== null) {
markRemoteSpawnAttempted();
Comment thread
iscekic marked this conversation as resolved.
Resolved
void submitWithVoiceSettled(async () => {
remoteSpawn.onStart();
await Promise.resolve();
});
return;
}
void submitWithVoiceSettled(createSessionFromDraft);
}, [createSessionFromDraft, remoteSpawn, runOnInstance, submitWithVoiceSettled]);
}, [
createSessionFromDraft,
markRemoteSpawnAttempted,
remoteSpawn,
runOnInstance,
submitWithVoiceSettled,
]);

return (
<View className="flex-1 bg-background">
<ScreenHeader title="New session" />
<NewSessionConfigureForm
attachments={attachments.attachments}
attachmentMax={AGENT_ATTACHMENT_MAX_FILES}
isCreating={isCreating}
isModelsError={isModelsError}
isLoadingModels={isLoadingModels}
mode={mode}
model={model}
variant={variant}
modelOptions={models}
initialPrompt={promptRef.current}
onChangeText={handlePromptChange}
onModeChange={setMode}
onModelSelect={handleModelSelect}
onAddAttachment={() => void handleAddAttachment()}
onRemoveAttachment={handleRemoveAttachment}
onRetryAttachment={handleRetryAttachment}
onRefetchModels={() => void refetchModels()}
onPrefillAttachments={addCandidates}
shareId={shareId}
voiceInputSettlerRef={voiceInputSettlerRef}
showRunOnSelector={showRunOnSelector}
runOnInstance={runOnInstance}
instanceList={instanceList}
isLoadingInstances={isLoadingInstances}
onChangeRunOnInstance={handleRunOnInstanceChange}
showInstanceDisconnectedNote={remoteSpawn.showInstanceDisconnectedNote}
view={view}
isRetrying={isRetrying}
onChangeRepo={setSelectedRepo}
onOpenGitHubIntegration={openGitHub}
onRefreshRepos={() => void refreshReposForceFresh()}
repositories={repositories}
selectedRepo={selectedRepo}
isStartDisabled={isStartDisabled}
isSpawningRemote={remoteSpawn.isSpawningRemote}
onStartSession={handleStartSession}
/>
{!draftState.settled ? (
<View className="flex-1" />
) : (
<NewSessionConfigureForm
attachments={attachments.attachments}
attachmentMax={AGENT_ATTACHMENT_MAX_FILES}
isCreating={isCreating}
isModelsError={isModelsError}
isLoadingModels={isLoadingModels}
mode={mode}
model={model}
variant={variant}
modelOptions={models}
initialPrompt={initialPrompt}
onChangeText={handlePromptChange}
onModeChange={setMode}
onModelSelect={handleModelSelect}
onAddAttachment={() => void handleAddAttachment()}
onRemoveAttachment={handleRemoveAttachment}
onRetryAttachment={handleRetryAttachment}
onRefetchModels={() => void refetchModels()}
onPrefillAttachments={addCandidates}
shareId={shareId}
voiceInputSettlerRef={voiceInputSettlerRef}
showRunOnSelector={showRunOnSelector}
runOnInstance={runOnInstance}
instanceList={instanceList}
isLoadingInstances={isLoadingInstances}
onChangeRunOnInstance={handleRunOnInstanceChange}
showInstanceDisconnectedNote={remoteSpawn.showInstanceDisconnectedNote}
view={view}
isRetrying={isRetrying}
onChangeRepo={setSelectedRepo}
onOpenGitHubIntegration={openGitHub}
onRefreshRepos={() => void refreshReposForceFresh()}
repositories={repositories}
selectedRepo={selectedRepo}
isStartDisabled={isStartDisabled}
isSpawningRemote={remoteSpawn.isSpawningRemote}
onStartSession={handleStartSession}
/>
)}
</View>
);
}
5 changes: 5 additions & 0 deletions apps/mobile/src/app/(app)/device-sessions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { DeviceSessionsScreen } from '@/components/device-sessions-screen';

export default function DeviceSessionsRoute() {
return <DeviceSessionsScreen />;
}
Loading