diff --git a/DESIGN.md b/DESIGN.md
index bfc8397b24..e043029a38 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -427,3 +427,7 @@ Kilo voice is clear, technical, calm, and direct. Use concrete verbs and specifi
- Invent status colors, spacing values, radii, or typography roles.
- Nest cards or use shadows as the default source of depth.
- Depend on color, hover, placeholders, or icon shape alone to convey meaning.
+
+## Mobile (Focus palette)
+
+The mobile app (`apps/mobile/`) intentionally does not use this token contract. It ships its own light/dark palette ("Focus", FL/FD) defined as CSS variables in `apps/mobile/src/global.css`, tuned for WCAG AA contrast against its own warmer neutral surfaces. This divergence is deliberate — mobile is not expected to converge onto the tokens above.
diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx
index 714c088adc..8b08037015 100644
--- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx
+++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx
@@ -8,15 +8,6 @@ export default function KiloClawLayout() {
return (
-
{
- if (conversationDetail.isError) {
- toast.error(getConversationRouteErrorMessage(conversationDetail.error));
- router.replace(redirectPath);
- return;
- }
if (routeDecision === 'not-found') {
toast.error('Conversation not found');
router.replace(redirectPath);
}
- }, [conversationDetail.error, conversationDetail.isError, redirectPath, routeDecision, router]);
+ }, [redirectPath, routeDecision, router]);
+
+ if (routeDecision === 'pending') {
+ return ;
+ }
+
+ if (routeDecision === 'retryable-error') {
+ return (
+ {
+ void conversationDetail.refetch();
+ }}
+ />
+ );
+ }
- if (
- !shouldRenderConversationScreen({
- detail: conversationDetail,
- routeSandboxId: sandboxId,
- }) ||
- !conversationDetail.data
- ) {
+ if (routeDecision !== 'ready' || !conversationDetail.data) {
return null;
}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/index.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/index.tsx
index aa120b9156..2ad85b76c7 100644
--- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/index.tsx
+++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/index.tsx
@@ -6,10 +6,13 @@ import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context';
export default function ChatSandboxIndex() {
const { 'sandbox-id': sandboxId } = useLocalSearchParams<{ 'sandbox-id': string }>();
- const { data: instances } = useAllKiloClawInstances();
+ const { data: instances, isPending: instancesPending } = useAllKiloClawInstances();
const instance = instances?.find(i => i.sandboxId === sandboxId);
- const sandboxLabel =
- instance?.botName ?? instance?.name ?? instance?.organizationName ?? 'KiloClaw';
+ // Blank until instances resolve — never show a "KiloClaw" placeholder that
+ // then flashes to the real instance name once the query settles.
+ const sandboxLabel = instancesPending
+ ? ''
+ : (instance?.botName ?? instance?.name ?? instance?.organizationName ?? 'KiloClaw');
return (
<>
diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/instance-picker.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/instance-picker.tsx
index ceab1bfc55..b790bae8e4 100644
--- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/instance-picker.tsx
+++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/instance-picker.tsx
@@ -1,20 +1,59 @@
import * as Haptics from 'expo-haptics';
-import { useLocalSearchParams, useRouter } from 'expo-router';
-import { Check } from 'lucide-react-native';
-import { Pressable, ScrollView, View } from 'react-native';
+import { type Href, useLocalSearchParams, useRouter } from 'expo-router';
+import { Check, Server } from 'lucide-react-native';
+import { Pressable, View } from 'react-native';
+import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { StatusBadge } from '@/components/kiloclaw/status-badge';
+import { EmptyState } from '@/components/empty-state';
+import { PickerSheet } from '@/components/picker-sheet';
import { QueryError } from '@/components/query-error';
+import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
-import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context';
+import { type ClawInstance, useAllKiloClawInstances } from '@/lib/hooks/use-instance-context';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { kiloclawInstanceSwitcherTitle } from '@/lib/kiloclaw-display';
import { chatSandboxPath } from '@/lib/kilo-chat-routes';
+function InstanceRow({
+ instance,
+ isCurrent,
+ onSelect,
+}: {
+ instance: ClawInstance;
+ isCurrent: boolean;
+ onSelect: (sandboxId: string) => void;
+}) {
+ const colors = useThemeColors();
+ const title = kiloclawInstanceSwitcherTitle(instance);
+ return (
+ {
+ onSelect(instance.sandboxId);
+ }}
+ accessibilityRole="button"
+ accessibilityLabel={`${title}${isCurrent ? ', current' : ''}`}
+ >
+
+
+ {title}
+
+
+
+ {instance.organizationName ?? 'Personal'}
+
+
+
+
+ {isCurrent ? : null}
+
+ );
+}
+
export default function InstancePickerScreen() {
const router = useRouter();
- const colors = useThemeColors();
const { currentId } = useLocalSearchParams<{ currentId: string }>();
const instancesQuery = useAllKiloClawInstances();
const { data: instances } = instancesQuery;
@@ -29,31 +68,22 @@ export default function InstancePickerScreen() {
router.push(chatSandboxPath(sandboxId));
};
- return (
-
-
-
- Switch Instance
- {
- router.back();
- }}
- hitSlop={8}
- accessibilityRole="button"
- accessibilityLabel="Done"
- className="absolute right-0 rounded-full bg-secondary px-4 py-2 active:opacity-70 will-change-pressable"
- >
- Done
-
-
-
+ const showList = !instancesQuery.isPending && !instancesQuery.isError;
+ const loadedInstances = instances ?? [];
+ return (
+ {
+ router.back();
+ }}
+ >
{instancesQuery.isPending ? (
-
-
-
-
-
+
+
+
+
+
) : null}
{instancesQuery.isError ? (
) : null}
- {!instancesQuery.isPending && !instancesQuery.isError
- ? (instances ?? []).map(instance => {
- const isCurrent = instance.sandboxId === currentId;
- const title = kiloclawInstanceSwitcherTitle(instance);
- return (
- {
- handleSelect(instance.sandboxId);
- }}
- accessibilityRole="button"
- accessibilityLabel={`${title}${isCurrent ? ', current' : ''}`}
- >
-
-
- {title}
-
-
-
- {instance.organizationName ?? 'Personal'}
-
-
-
-
- {isCurrent ? : null}
-
- );
- })
- : null}
-
+ {showList && loadedInstances.length === 0 ? (
+ {
+ router.push('/(app)/onboarding' as Href);
+ }}
+ >
+ Set up KiloClaw
+
+ }
+ />
+ ) : null}
+ {showList ? (
+
+ {loadedInstances.map(instance => (
+
+ ))}
+
+ ) : null}
+
);
}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx
index 838e837fb2..4b5eb9371a 100644
--- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx
+++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx
@@ -1,10 +1,12 @@
import { type Href, useRouter } from 'expo-router';
-import { useCallback, useState } from 'react';
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 {
+ EmptyStateContent,
+ resolveAccessRequiredSubcase,
+} from '@/components/kiloclaw/empty-state-content';
import { getKiloClawEntryDecision } from '@/components/kiloclaw/instance-entry-state';
import { InstanceListScreen } from '@/components/kiloclaw/instance-list-screen';
import { QueryError } from '@/components/query-error';
@@ -13,6 +15,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { useForegroundInvalidateKiloclawState } from '@/lib/hooks/use-foreground-invalidate-kiloclaw-state';
import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context';
import { useKiloClawMobileOnboardingState } from '@/lib/hooks/use-kiloclaw-queries';
+import { useManualRefresh } from '@/lib/hooks/use-manual-refresh';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { useUnreadCounts } from '@/lib/hooks/use-unread-counts';
import { chatSandboxPath } from '@/lib/kilo-chat-routes';
@@ -22,13 +25,18 @@ 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;
const { byBadgeBucket: unreadByBadgeBucket } = useUnreadCounts();
const refetchInstances = instancesQuery.refetch;
const entryDecision = getKiloClawEntryDecision(instances);
- const onboardingQuery = useKiloClawMobileOnboardingState(entryDecision.kind === 'empty');
+ // Always enabled (not just for the empty-list case) so a personal
+ // billing/access issue is still surfaced as a card annotation when the
+ // list is non-empty — see `personalAccessIssue` below.
+ const onboardingQuery = useKiloClawMobileOnboardingState();
+ const personalAccessIssue = onboardingQuery.data
+ ? resolveAccessRequiredSubcase(onboardingQuery.data)
+ : null;
useForegroundInvalidateKiloclawState();
const showInstanceSkeleton = entryDecision.kind === 'loading' || onboardingQuery.isPending;
@@ -36,26 +44,26 @@ export default function KiloClawTab() {
paddingBottom: getTabBarOverlayHeight(bottom, Platform.OS),
};
- const handleRefresh = useCallback(() => {
- void (async () => {
- setManualRefreshing(true);
- try {
- await refetchInstances();
- } finally {
- setManualRefreshing(false);
- }
- })();
- }, [refetchInstances]);
+ const [manualRefreshing, handleRefresh] = useManualRefresh(
+ refetchInstances,
+ 'Could not refresh. Showing the last saved list.'
+ );
- const onboardingQueryEnabled = entryDecision.kind === 'empty';
+ // A background billing-check failure while the list is already showing
+ // shouldn't blank the screen — only block on it before we have any
+ // instances to show (preserve stale/cached data).
const hasQueryError =
- instancesQuery.isError || (onboardingQueryEnabled && onboardingQuery.isError);
+ instancesQuery.isError || (entryDecision.kind !== 'list' && onboardingQuery.isError);
if (hasQueryError) {
return (
-
+
{
router.push('/(app)/onboarding' as Href);
}}
+ personalAccessIssue={personalAccessIssue}
/>
);
}
@@ -98,10 +107,10 @@ export default function KiloClawTab() {
{showInstanceSkeleton || onboardingQuery.data === undefined ? (
-
-
-
-
+
+
+
+
) : (
();
- const renameConversation = useRenameConversation(client);
- const initialTitle = typeof title === 'string' ? title : '';
-
- return (
- {
- router.back();
- }}
- onSave={nextTitle => {
- if (!conversationId) {
- return;
- }
- renameConversation.mutate(
- { conversationId, title: nextTitle, sandboxId },
- {
- onSuccess: () => {
- router.back();
- },
- }
- );
- }}
- />
- );
-}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/_layout.tsx
new file mode 100644
index 0000000000..0500e972fe
--- /dev/null
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/_layout.tsx
@@ -0,0 +1,15 @@
+import { Stack, useLocalSearchParams } from 'expo-router';
+
+import { type ReviewerPlatform } from '@/lib/code-reviewer-config';
+import { useReviewerEditGuard } from '@/lib/hooks/use-code-reviewer';
+
+// Groups the config-editing routes (style/gate/focus-areas/repos/
+// instructions) that redirect a read-only viewer back to the overview.
+// The `(edit)` group doesn't add a URL segment, so routes keep their
+// existing paths. Params are already validated by `[platform]/_layout.tsx`.
+export default function CodeReviewerEditLayout() {
+ const { scope, platform } = useLocalSearchParams<{ scope: string; platform: ReviewerPlatform }>();
+ useReviewerEditGuard(scope, platform);
+
+ return ;
+}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/focus-areas.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/focus-areas.tsx
similarity index 56%
rename from apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/focus-areas.tsx
rename to apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/focus-areas.tsx
index a6affc3dc9..5584d1e536 100644
--- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/focus-areas.tsx
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/focus-areas.tsx
@@ -1,32 +1,26 @@
-import * as Haptics from 'expo-haptics';
import { useLocalSearchParams } from 'expo-router';
-import { Check } from 'lucide-react-native';
-import { Pressable, ScrollView, View } from 'react-native';
+import { View } from 'react-native';
import { ScreenHeader } from '@/components/screen-header';
import { Text } from '@/components/ui/text';
-import { asReviewerPlatform, REVIEW_FOCUS_AREAS } from '@/lib/code-reviewer-config';
+import { ChoiceRow } from '@/components/ui/choice-row';
+import { TabScreenScrollView } from '@/components/tab-screen';
+import { REVIEW_FOCUS_AREAS, type ReviewerPlatform } from '@/lib/code-reviewer-config';
import {
useReviewConfig,
useReviewConfigCacheReader,
useSaveReviewConfig,
} from '@/lib/hooks/use-code-reviewer';
-import { useThemeColors } from '@/lib/hooks/use-theme-colors';
export default function FocusAreasRoute() {
- const { scope, platform: rawPlatform } = useLocalSearchParams<{
- scope: string;
- platform: string;
- }>();
- const platform = asReviewerPlatform(rawPlatform);
- const colors = useThemeColors();
+ const { scope, platform } = useLocalSearchParams<{ scope: string; platform: ReviewerPlatform }>();
const { data } = useReviewConfig(scope, platform);
const save = useSaveReviewConfig(scope, platform);
const readConfig = useReviewConfigCacheReader(scope, platform);
const selected = data?.focusAreas ?? [];
+ const disabled = data == null;
const toggleArea = (area: string) => {
- void Haptics.selectionAsync();
// Read the cache at call time, not the render-time snapshot above, so
// two rapid taps each build the next array from the latest committed
// selection instead of dropping one another.
@@ -39,24 +33,25 @@ export default function FocusAreasRoute() {
return (
-
-
+
+
Leave all unselected to review everything.
{REVIEW_FOCUS_AREAS.map(area => (
- {
toggleArea(area);
}}
- >
- {area}
-
-
+ />
))}
-
+
);
}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/gate.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/gate.tsx
similarity index 62%
rename from apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/gate.tsx
rename to apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/gate.tsx
index 2abfefebf1..78a0210433 100644
--- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/gate.tsx
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/gate.tsx
@@ -1,7 +1,7 @@
import { useLocalSearchParams } from 'expo-router';
import { OptionList } from '@/components/code-reviewer/option-list';
-import { asReviewerPlatform, GATE_THRESHOLDS } from '@/lib/code-reviewer-config';
+import { GATE_THRESHOLDS, type ReviewerPlatform } from '@/lib/code-reviewer-config';
import { useReviewConfig, useSaveReviewConfig } from '@/lib/hooks/use-code-reviewer';
const DESCRIPTIONS = {
@@ -12,23 +12,19 @@ const DESCRIPTIONS = {
} as const;
export default function GateThresholdRoute() {
- const { scope, platform: rawPlatform } = useLocalSearchParams<{
- scope: string;
- platform: string;
- }>();
- const platform = asReviewerPlatform(rawPlatform);
+ const { scope, platform } = useLocalSearchParams<{ scope: string; platform: ReviewerPlatform }>();
const { data } = useReviewConfig(scope, platform);
const save = useSaveReviewConfig(scope, platform);
return (
{
- save.mutate({ gateThreshold: value });
- }}
+ disabled={data == null}
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ onSelect={value => save.mutateAsync({ gateThreshold: value })}
/>
);
}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/instructions.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/instructions.tsx
similarity index 85%
rename from apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/instructions.tsx
rename to apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/instructions.tsx
index 3552b75a23..63a5f82bd8 100644
--- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/instructions.tsx
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/instructions.tsx
@@ -1,13 +1,14 @@
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useRef } from 'react';
-import { ScrollView, TextInput, View } from 'react-native';
+import { TextInput, View } from 'react-native';
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
import { ScreenHeader } from '@/components/screen-header';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
-import { asReviewerPlatform } from '@/lib/code-reviewer-config';
+import { TabScreenScrollView } from '@/components/tab-screen';
+import { type ReviewerPlatform } from '@/lib/code-reviewer-config';
import { useReviewConfig, useSaveReviewConfig } from '@/lib/hooks/use-code-reviewer';
// Mounted only once `data != null`, so useRef(initial) captures the real
@@ -53,21 +54,17 @@ function InstructionsEditor({
}
export default function InstructionsRoute() {
- const { scope, platform: rawPlatform } = useLocalSearchParams<{
- scope: string;
- platform: string;
- }>();
- const platform = asReviewerPlatform(rawPlatform);
+ const { scope, platform } = useLocalSearchParams<{ scope: string; platform: ReviewerPlatform }>();
const router = useRouter();
const { data } = useReviewConfig(scope, platform);
const save = useSaveReviewConfig(scope, platform);
return (
-
-
+
@@ -89,7 +86,7 @@ export default function InstructionsRoute() {
/>
)}
-
+
);
}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/repos.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/repos.tsx
new file mode 100644
index 0000000000..ff5f2114d7
--- /dev/null
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/repos.tsx
@@ -0,0 +1,218 @@
+import { useLocalSearchParams } from 'expo-router';
+import { FolderGit2 } from 'lucide-react-native';
+import { View } from 'react-native';
+
+import { EmptyState } from '@/components/empty-state';
+import { QueryError } from '@/components/query-error';
+import { RepoToggleRow } from '@/components/repo-toggle-row';
+import { ScreenHeader } from '@/components/screen-header';
+import { Button } from '@/components/ui/button';
+import { ChoiceRow } from '@/components/ui/choice-row';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Text } from '@/components/ui/text';
+import { TabScreenScrollView } from '@/components/tab-screen';
+import { getGitHubIntegrationUrl } from '@/lib/agent-github-integration';
+import { PLATFORM_CAPABILITIES, type ReviewerPlatform } from '@/lib/code-reviewer-config';
+import { WEB_BASE_URL } from '@/lib/config';
+import { openExternalUrl } from '@/lib/external-link';
+import {
+ PERSONAL_SCOPE,
+ useBitbucketReadiness,
+ useGitHubRepositories,
+ useGitLabRepositories,
+ useReviewConfig,
+ useReviewConfigCacheReader,
+ useSaveReviewConfig,
+} from '@/lib/hooks/use-code-reviewer';
+import { getBitbucketIntegrationUrl, getGitLabIntegrationUrl } from '@/lib/integration-urls';
+
+export default function ReposRoute() {
+ const { scope, platform } = useLocalSearchParams<{ scope: string; platform: ReviewerPlatform }>();
+ const { data } = useReviewConfig(scope, platform);
+ const save = useSaveReviewConfig(scope, platform);
+ const readConfig = useReviewConfigCacheReader(scope, platform);
+ const capabilities = PLATFORM_CAPABILITIES[platform];
+ const mode = data?.repositorySelectionMode ?? 'all';
+ const githubRepos = useGitHubRepositories(scope, platform === 'github' && mode === 'selected');
+ const gitlabRepos = useGitLabRepositories(scope, platform === 'gitlab');
+ const bitbucketReadiness = useBitbucketReadiness(scope);
+ const bitbucketRepos =
+ bitbucketReadiness.data?.repositoryCache.status === 'available'
+ ? bitbucketReadiness.data.repositoryCache.repositories.map(repo => ({
+ id: repo.id,
+ fullName: repo.fullName,
+ private: repo.private,
+ }))
+ : [];
+ const reposByPlatform = {
+ github: {
+ isLoading: githubRepos.isLoading,
+ isError: githubRepos.isError,
+ isFetching: githubRepos.isFetching,
+ refetch: () => void githubRepos.refetch(),
+ rows: githubRepos.data?.repositories ?? [],
+ },
+ gitlab: {
+ isLoading: gitlabRepos.isLoading,
+ isError: gitlabRepos.isError,
+ isFetching: gitlabRepos.isFetching,
+ refetch: () => void gitlabRepos.refetch(),
+ rows: gitlabRepos.data?.repositories ?? [],
+ },
+ bitbucket: {
+ isLoading: bitbucketReadiness.isLoading,
+ isError: bitbucketReadiness.isError,
+ isFetching: bitbucketReadiness.isFetching,
+ refetch: () => void bitbucketReadiness.refetch(),
+ rows: bitbucketRepos,
+ },
+ };
+ const {
+ isLoading: reposLoading,
+ isError: reposError,
+ isFetching: reposFetching,
+ refetch: refetchRepos,
+ rows: repoRows,
+ } = reposByPlatform[platform];
+ const selectedIds = data?.selectedRepositoryIds ?? [];
+ const configDisabled = data == null;
+ const bitbucketNotReady =
+ platform === 'bitbucket' && bitbucketReadiness.data?.repositoryCache.status !== 'available';
+ const confirmedEmpty =
+ !reposLoading && !reposError && !bitbucketNotReady && repoRows.length === 0;
+ const orgScope = scope === PERSONAL_SCOPE ? undefined : scope;
+ const manageRepoAccessUrlByPlatform: Partial> = {
+ github: getGitHubIntegrationUrl(WEB_BASE_URL, orgScope),
+ gitlab: getGitLabIntegrationUrl(WEB_BASE_URL, orgScope),
+ };
+ const manageRepoAccessUrl = manageRepoAccessUrlByPlatform[platform];
+ const emptyStateCopyByPlatform: Record =
+ {
+ github: {
+ title: 'Install the GitHub app on repositories',
+ description: 'Grant the Kilo GitHub App access to the repositories you want reviewed.',
+ },
+ gitlab: {
+ title: 'No repositories found',
+ description: 'You may need to grant access to more groups or projects on GitLab.',
+ },
+ bitbucket: {
+ title: 'No repositories found',
+ description: 'No repositories are available in this Bitbucket workspace.',
+ },
+ };
+ const emptyStateCopy = emptyStateCopyByPlatform[platform];
+
+ const setMode = (nextMode: 'all' | 'selected') => {
+ save.mutate({ repositorySelectionMode: nextMode });
+ };
+
+ const toggleRepo = (id: number | string) => {
+ // Read the cache at call time, not the render-time snapshot above, so
+ // two rapid taps each build the next array from the latest committed
+ // selection instead of dropping one another.
+ const current = readConfig()?.selectedRepositoryIds ?? [];
+ const next = current.includes(id)
+ ? current.filter(existing => existing !== id)
+ : [...current, id];
+ save.mutate({ selectedRepositoryIds: next });
+ };
+
+ return (
+
+
+
+ {capabilities.selectionModePicker &&
+ (['all', 'selected'] as const).map(option => (
+ {
+ setMode(option);
+ }}
+ />
+ ))}
+
+ {(!capabilities.selectionModePicker || mode === 'selected') && (
+
+
+ Repositories
+
+ {reposLoading && (
+
+
+
+
+ )}
+
+ {!reposLoading && reposError && (
+
+ )}
+
+ {!reposLoading && !reposError && bitbucketNotReady && (
+
+
+ Repositories unavailable — finish Bitbucket setup on kilo.ai.
+
+
+
+ )}
+
+ {confirmedEmpty && (
+ {
+ void openExternalUrl(manageRepoAccessUrl, { label: 'repository access' });
+ }}
+ >
+ Manage access
+
+ ) : undefined
+ }
+ />
+ )}
+
+ {repoRows.map(repo => (
+ {
+ toggleRepo(repo.id);
+ }}
+ />
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/style.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/style.tsx
similarity index 63%
rename from apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/style.tsx
rename to apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/style.tsx
index 2125403248..fe5300b3c8 100644
--- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/style.tsx
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/style.tsx
@@ -1,7 +1,7 @@
import { useLocalSearchParams } from 'expo-router';
import { OptionList } from '@/components/code-reviewer/option-list';
-import { asReviewerPlatform, REVIEW_STYLES } from '@/lib/code-reviewer-config';
+import { REVIEW_STYLES, type ReviewerPlatform } from '@/lib/code-reviewer-config';
import { useReviewConfig, useSaveReviewConfig } from '@/lib/hooks/use-code-reviewer';
const DESCRIPTIONS = {
@@ -12,23 +12,19 @@ const DESCRIPTIONS = {
} as const;
export default function ReviewStyleRoute() {
- const { scope, platform: rawPlatform } = useLocalSearchParams<{
- scope: string;
- platform: string;
- }>();
- const platform = asReviewerPlatform(rawPlatform);
+ const { scope, platform } = useLocalSearchParams<{ scope: string; platform: ReviewerPlatform }>();
const { data } = useReviewConfig(scope, platform);
const save = useSaveReviewConfig(scope, platform);
return (
{
- save.mutate({ reviewStyle: value });
- }}
+ disabled={data == null}
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ onSelect={value => save.mutateAsync({ reviewStyle: value })}
/>
);
}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/_layout.tsx
new file mode 100644
index 0000000000..88b793d5cb
--- /dev/null
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/_layout.tsx
@@ -0,0 +1,18 @@
+import { type Href, Stack } from 'expo-router';
+
+import { InvalidRouteState } from '@/components/invalid-route-state';
+import { useValidatedReviewerRouteParams } from '@/lib/hooks/use-reviewer-route-params';
+
+// Single validation point for the `scope`+`platform` params — every route
+// under `[platform]/` is a descendant of this layout, so rejecting an
+// invalid combination here blocks all of them before any query/mutation
+// runs. Mirrors security-agent's `[scope]/_layout.tsx`.
+export default function CodeReviewerPlatformLayout() {
+ const params = useValidatedReviewerRouteParams();
+
+ if (!params) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/index.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/index.tsx
index de2418ea4d..cc78b35ecd 100644
--- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/index.tsx
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/index.tsx
@@ -1,12 +1,13 @@
import { useLocalSearchParams } from 'expo-router';
import { PlatformOverviewScreen } from '@/components/code-reviewer/platform-overview-screen';
-import { asReviewerPlatform } from '@/lib/code-reviewer-config';
+import { type ReviewerPlatform } from '@/lib/code-reviewer-config';
+// The `[platform]/_layout.tsx` above already rejects a malformed scope+
+// platform combination via InvalidRouteState, so this route never mounts
+// with bad params — no need to re-validate.
export default function CodeReviewerPlatformRoute() {
- const { scope, platform: rawPlatform } = useLocalSearchParams<{
- scope: string;
- platform: string;
- }>();
- return ;
+ const { scope, platform } = useLocalSearchParams<{ scope: string; platform: ReviewerPlatform }>();
+
+ return ;
}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/repos.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/repos.tsx
deleted file mode 100644
index 852a0b8a8d..0000000000
--- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/repos.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-import * as Haptics from 'expo-haptics';
-import { useLocalSearchParams } from 'expo-router';
-import { Check, Lock } from 'lucide-react-native';
-import { Pressable, ScrollView, View } from 'react-native';
-
-import { ScreenHeader } from '@/components/screen-header';
-import { Skeleton } from '@/components/ui/skeleton';
-import { Text } from '@/components/ui/text';
-import { asReviewerPlatform, PLATFORM_CAPABILITIES } from '@/lib/code-reviewer-config';
-import {
- useBitbucketReadiness,
- useGitHubRepositories,
- useGitLabRepositories,
- useReviewConfig,
- useReviewConfigCacheReader,
- useSaveReviewConfig,
-} from '@/lib/hooks/use-code-reviewer';
-import { useThemeColors } from '@/lib/hooks/use-theme-colors';
-
-export default function ReposRoute() {
- const { scope, platform: rawPlatform } = useLocalSearchParams<{
- scope: string;
- platform: string;
- }>();
- const platform = asReviewerPlatform(rawPlatform);
- const colors = useThemeColors();
- const { data } = useReviewConfig(scope, platform);
- const save = useSaveReviewConfig(scope, platform);
- const readConfig = useReviewConfigCacheReader(scope, platform);
- const capabilities = PLATFORM_CAPABILITIES[platform];
- const mode = data?.repositorySelectionMode ?? 'all';
- const githubRepos = useGitHubRepositories(scope, platform === 'github' && mode === 'selected');
- const gitlabRepos = useGitLabRepositories(scope, platform === 'gitlab');
- const bitbucketReadiness = useBitbucketReadiness(scope);
- const bitbucketRepos =
- bitbucketReadiness.data?.repositoryCache.status === 'available'
- ? bitbucketReadiness.data.repositoryCache.repositories.map(repo => ({
- id: repo.id,
- fullName: repo.fullName,
- private: repo.private,
- }))
- : [];
- const reposByPlatform = {
- github: { isLoading: githubRepos.isLoading, rows: githubRepos.data?.repositories ?? [] },
- gitlab: { isLoading: gitlabRepos.isLoading, rows: gitlabRepos.data?.repositories ?? [] },
- bitbucket: { isLoading: bitbucketReadiness.isLoading, rows: bitbucketRepos },
- };
- const { isLoading: reposLoading, rows: repoRows } = reposByPlatform[platform];
- const selectedIds = data?.selectedRepositoryIds ?? [];
-
- const setMode = (nextMode: 'all' | 'selected') => {
- void Haptics.selectionAsync();
- save.mutate({ repositorySelectionMode: nextMode });
- };
-
- const toggleRepo = (id: number | string) => {
- void Haptics.selectionAsync();
- // Read the cache at call time, not the render-time snapshot above, so
- // two rapid taps each build the next array from the latest committed
- // selection instead of dropping one another.
- const current = readConfig()?.selectedRepositoryIds ?? [];
- const next = current.includes(id)
- ? current.filter(existing => existing !== id)
- : [...current, id];
- save.mutate({ selectedRepositoryIds: next });
- };
-
- return (
-
-
-
- {capabilities.selectionModePicker &&
- (['all', 'selected'] as const).map(option => (
- {
- setMode(option);
- }}
- >
-
- {option === 'all' ? 'All repositories' : 'Selected repositories'}
-
-
-
- ))}
-
- {(!capabilities.selectionModePicker || mode === 'selected') && (
-
-
- Repositories
-
- {reposLoading && (
-
-
-
-
- )}
- {!reposLoading &&
- platform === 'bitbucket' &&
- bitbucketReadiness.data?.repositoryCache.status !== 'available' && (
-
- Repositories unavailable — finish Bitbucket setup on kilo.ai.
-
- )}
- {repoRows.map(repo => (
- {
- toggleRepo(repo.id);
- }}
- >
-
- {repo.private ? : null}
-
- {repo.fullName}
-
-
-
-
- ))}
-
- )}
-
-
- );
-}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/_layout.tsx
index c06b11e953..3bbcc70d78 100644
--- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/_layout.tsx
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/_layout.tsx
@@ -1,16 +1,9 @@
import { Stack } from 'expo-router';
-import { Platform, StatusBar, useWindowDimensions } from 'react-native';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
-// Mirrors apps/(app)/_layout.tsx's Android-safe full-sheet detent — Android
-// formSheets can't hit 1.0 without clipping under the status bar.
+import { useFormSheetDetents } from '@/lib/form-sheet';
+
export default function OrganizationLayout() {
- const { height } = useWindowDimensions();
- const { top } = useSafeAreaInsets();
- const androidTopInset = top > 0 ? top : (StatusBar.currentHeight ?? 0);
- const androidFullSheetDetent =
- height > 0 ? Math.max(0.5, (height - androidTopInset) / height) : 1;
- const fullSheetDetent = Platform.OS === 'android' ? androidFullSheetDetent : 1;
+ const { fullSheetDetent } = useFormSheetDetents();
const sheetOptions = {
presentation: 'formSheet' as const,
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/_layout.tsx
index 68a4fe59b7..aaa28bdcc5 100644
--- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/_layout.tsx
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/_layout.tsx
@@ -1,22 +1,24 @@
-import { Stack, useLocalSearchParams } from 'expo-router';
-import { Platform, StatusBar, useWindowDimensions } from 'react-native';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { type Href, Stack, useLocalSearchParams } from 'expo-router';
+import { InvalidRouteState } from '@/components/invalid-route-state';
import { SecurityAgentCommandObserver } from '@/components/security-agent/security-agent-command-observer';
+import { useFormSheetDetents } from '@/lib/form-sheet';
+import { parseParam } from '@/lib/route-params';
// Mounts exactly one command observer per scope alongside a headerless Stack,
// so it stays mounted across Dashboard/Findings/Settings navigation without
-// ever running twice for the same scope.
+// ever running twice for the same scope. Also the single validation point
+// for the `scope` param — every route under `[scope]/` is a descendant of
+// this layout, so rejecting an invalid scope here blocks all of them before
+// any query/mutation runs.
export default function SecurityAgentScopeLayout() {
- const { scope } = useLocalSearchParams<{ scope: string }>();
- const { height } = useWindowDimensions();
- const { top } = useSafeAreaInsets();
- // Mirrors apps/(app)/_layout.tsx's Android-safe full-sheet detent — Android
- // formSheets can't hit 1.0 without clipping under the status bar.
- const androidTopInset = top > 0 ? top : (StatusBar.currentHeight ?? 0);
- const androidFullSheetDetent =
- height > 0 ? Math.max(0.5, (height - androidTopInset) / height) : 1;
- const fullSheetDetent = Platform.OS === 'android' ? androidFullSheetDetent : 1;
+ const { scope: rawScope } = useLocalSearchParams<{ scope: string }>();
+ const scope = parseParam(rawScope);
+ const { fullSheetDetent } = useFormSheetDetents();
+
+ if (!scope) {
+ return ;
+ }
return (
<>
@@ -31,6 +33,15 @@ export default function SecurityAgentScopeLayout() {
headerShown: false,
}}
/>
+
>
);
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/dismiss/[id].tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/dismiss/[id].tsx
index cb392070f9..2a0c243630 100644
--- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/dismiss/[id].tsx
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/dismiss/[id].tsx
@@ -1,8 +1,18 @@
-import { useLocalSearchParams } from 'expo-router';
+import { type Href, useLocalSearchParams } from 'expo-router';
+import { InvalidRouteState } from '@/components/invalid-route-state';
import { DismissFindingScreen } from '@/components/security-agent/dismiss-finding-screen';
+import { parseParam } from '@/lib/route-params';
export default function SecurityAgentDismissFindingRoute() {
- const { scope, id } = useLocalSearchParams<{ scope: string; id: string }>();
- return ;
+ const { scope, id: rawId } = useLocalSearchParams<{ scope: string; id: string }>();
+ const findingId = parseParam(rawId);
+
+ if (!findingId) {
+ return (
+
+ );
+ }
+
+ return ;
}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/filter.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/filter.tsx
new file mode 100644
index 0000000000..39124efc6b
--- /dev/null
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/filter.tsx
@@ -0,0 +1,59 @@
+import { type SecurityFindingFilters } from '@kilocode/app-shared/security-agent';
+import { useFocusEffect, useRouter } from 'expo-router';
+import { Info } from 'lucide-react-native';
+import { useCallback, useState } from 'react';
+import { View } from 'react-native';
+
+import { EmptyState } from '@/components/empty-state';
+import { FindingFilterModal } from '@/components/security-agent/finding-filter-modal';
+import {
+ clearSecurityFindingFilterBridge,
+ getSecurityFindingFilterBridge,
+} from '@/lib/security-finding-filter-bridge';
+
+export default function SecurityAgentFilterFindingsRoute() {
+ const router = useRouter();
+ const [bridge, setBridge] = useState(() => getSecurityFindingFilterBridge());
+
+ const handleClose = useCallback(() => {
+ router.back();
+ }, [router]);
+
+ const handleApply = useCallback(
+ (filters: SecurityFindingFilters) => {
+ bridge?.onApply(filters);
+ },
+ [bridge]
+ );
+
+ useFocusEffect(
+ useCallback(() => {
+ setBridge(getSecurityFindingFilterBridge());
+ return () => {
+ clearSecurityFindingFilterBridge();
+ };
+ }, [])
+ );
+
+ if (!bridge) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/findings/[id].tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/findings/[id].tsx
index f6ef3dd188..f47d2e08e2 100644
--- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/findings/[id].tsx
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/findings/[id].tsx
@@ -1,8 +1,18 @@
-import { useLocalSearchParams } from 'expo-router';
+import { type Href, useLocalSearchParams } from 'expo-router';
+import { InvalidRouteState } from '@/components/invalid-route-state';
import { FindingDetailScreen } from '@/components/security-agent/finding-detail-screen';
+import { parseParam } from '@/lib/route-params';
export default function SecurityAgentFindingDetailRoute() {
- const { scope, id } = useLocalSearchParams<{ scope: string; id: string }>();
- return ;
+ const { scope, id: rawId } = useLocalSearchParams<{ scope: string; id: string }>();
+ const findingId = parseParam(rawId);
+
+ if (!findingId) {
+ return (
+
+ );
+ }
+
+ return ;
}
diff --git a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx
index 12cc327d16..c4a236fcc9 100644
--- a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx
+++ b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx
@@ -42,8 +42,7 @@ export default function TabsLayout() {
const colors = useThemeColors();
const { bottom } = useSafeAreaInsets();
const pathParts = pathname.split('/').filter(Boolean);
- const hideTabs =
- pathParts[0] === 'chat' && pathParts.length === 3 && pathParts[2] !== 'rename-conversation';
+ const hideTabs = pathParts[0] === 'chat' && pathParts.length === 3;
return (
0 ? top : (StatusBar.currentHeight ?? 0);
- const androidFullSheetDetent =
- height > 0 ? Math.max(0.5, (height - androidTopInset) / height) : 1;
- const fullSheetDetent = Platform.OS === 'android' ? androidFullSheetDetent : 1;
+ const { fullSheetDetent } = useFormSheetDetents();
return (
@@ -57,8 +51,7 @@ export default function AppLayout() {
presentation: 'formSheet',
sheetAllowedDetents: [0.5],
sheetGrabberVisible: true,
- headerShown: true,
- title: 'Select Mode',
+ headerShown: false,
}}
/>
();
const trpc = useTRPC();
+ const router = useRouter();
const sessionQuery = useQuery({
...trpc.cliSessionsV2.get.queryOptions(
{ session_id: sessionId },
@@ -32,8 +38,9 @@ export default function SessionDetailScreen() {
if (routeOrganizationId === undefined && sessionQuery.isPending) {
return (
-
-
+
+
+
);
}
@@ -43,15 +50,23 @@ export default function SessionDetailScreen() {
-
- Failed to load session details
-
- void sessionQuery.refetch()}
+ void sessionQuery.refetch()}
+ isRetrying={sessionQuery.isFetching}
+ />
+
+ Back to sessions
+
);
diff --git a/apps/mobile/src/app/(app)/agent-chat/mode-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/mode-picker.tsx
index 7fad91e870..00d5bc2111 100644
--- a/apps/mobile/src/app/(app)/agent-chat/mode-picker.tsx
+++ b/apps/mobile/src/app/(app)/agent-chat/mode-picker.tsx
@@ -6,6 +6,7 @@ import { FlatList, Pressable, View } from 'react-native';
import { getModeIcon, MODE_OPTIONS, type ModeOption } from '@/components/agents/mode-options';
import { type AgentMode } from '@/components/agents/mode-selector';
+import { PickerSheet } from '@/components/picker-sheet';
import { Text } from '@/components/ui/text';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { clearModePickerBridge, getModePickerBridge } from '@/lib/picker-bridge';
@@ -13,35 +14,39 @@ import { clearModePickerBridge, getModePickerBridge } from '@/lib/picker-bridge'
export default function ModePickerScreen() {
const router = useRouter();
const colors = useThemeColors();
- const [currentValue, setCurrentValue] = useState(null);
- const [onSelect, setOnSelect] = useState<((mode: AgentMode) => void) | null>(null);
+ // Lazy init reads the bridge synchronously on first render — no effect, no
+ // "No options available" flash before a later effect populates state.
+ const [bridge] = useState(() => getModePickerBridge());
- useEffect(() => {
- const bridge = getModePickerBridge();
- if (bridge) {
- setCurrentValue(bridge.currentValue);
- // Wrap in a thunk so React doesn't call the function
- setOnSelect(() => bridge.onSelect);
- }
- }, []);
+ useEffect(
+ () => () => {
+ clearModePickerBridge();
+ },
+ []
+ );
function handleSelect(mode: AgentMode) {
void Haptics.selectionAsync();
- onSelect?.(mode);
+ bridge?.onSelect(mode);
clearModePickerBridge();
router.back();
}
- if (!onSelect) {
+ if (!bridge) {
return (
-
-
- No options available
-
-
+ {
+ router.back();
+ }}
+ scrollable={false}
+ expired
+ />
);
}
+ const currentValue = bridge.currentValue;
+
function renderItem({ item }: { item: ModeOption }) {
const Icon = getModeIcon(item.value);
const selected = item.value === currentValue;
@@ -58,9 +63,7 @@ export default function ModePickerScreen() {
{item.label}
-
- {item.description}
-
+ {item.description}
{selected && }
@@ -68,13 +71,20 @@ export default function ModePickerScreen() {
}
return (
- item.value}
- renderItem={renderItem}
- contentInsetAdjustmentBehavior="automatic"
- ItemSeparatorComponent={() => }
- />
+ {
+ router.back();
+ }}
+ scrollable={false}
+ >
+ item.value}
+ renderItem={renderItem}
+ ItemSeparatorComponent={() => }
+ />
+
);
}
diff --git a/apps/mobile/src/app/(app)/agent-chat/new.tsx b/apps/mobile/src/app/(app)/agent-chat/new.tsx
index 3f604469a2..0205427387 100644
--- a/apps/mobile/src/app/(app)/agent-chat/new.tsx
+++ b/apps/mobile/src/app/(app)/agent-chat/new.tsx
@@ -27,6 +27,7 @@ import { RepoSelector } from '@/components/agents/repo-selector';
import { useTextHeight } from '@/components/agents/use-text-height';
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
+import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
import { invalidateAgentSessionQueries } from '@/lib/agent-session-cache';
import {
@@ -46,6 +47,7 @@ import { useModelPreferences } from '@/lib/hooks/use-model-preferences';
import { usePersistedAgentModel } from '@/lib/hooks/use-persisted-agent-model';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { trpcClient, useTRPC } from '@/lib/trpc';
+import { cn } from '@/lib/utils';
const PROMPT_INPUT_DEFAULT_LINES = 3;
const PROMPT_INPUT_MAX_LINES = 6;
@@ -83,6 +85,7 @@ export default function NewSessionScreen() {
// Prompt ref (uncontrolled TextInput on iOS)
const promptRef = useRef('');
+ const [hasPrompt, setHasPrompt] = useState(false);
const [promptInputWidth, setPromptInputWidth] = useState(0);
const promptMeasure = useTextHeight({
minHeight: PROMPT_INPUT_MIN_HEIGHT,
@@ -94,7 +97,12 @@ export default function NewSessionScreen() {
});
// ── Models ───────────────────────────────────────────────────────
- const { models } = useAvailableModels(organizationId);
+ const {
+ models,
+ isLoading: isLoadingModels,
+ isError: isModelsError,
+ refetch: refetchModels,
+ } = useAvailableModels(organizationId);
const { setLastSelected: persistServerLastSelected } = useModelPreferences(organizationId);
const { saveModel } = usePersistedAgentModel();
const autoSelected = useAutoSelectModel(models, organizationId);
@@ -113,6 +121,7 @@ export default function NewSessionScreen() {
const {
data: repoData,
isLoading: isLoadingRepos,
+ isError: isReposError,
isRefetching: isRefetchingRepos,
refetch: refetchRepos,
} = useQuery(
@@ -164,23 +173,6 @@ export default function NewSessionScreen() {
const handleCreate = useCallback(async () => {
const prompt = promptRef.current.trim();
- // The backend requires a non-empty prompt even when attachments are present.
- if (!prompt) {
- toast.error('Enter a prompt first.');
- return;
- }
- if (!selectedRepo) {
- toast.error('Select a repository first.');
- return;
- }
- if (!model) {
- toast.error('Select a model first.');
- return;
- }
- if (attachments.isUploading) {
- toast.error('Wait for attachments to finish uploading.');
- return;
- }
if (prompt.startsWith('/') && attachments.attachments.length > 0) {
toast.error('Attachments cannot be sent with slash commands.');
return;
@@ -267,9 +259,18 @@ export default function NewSessionScreen() {
setPromptInputWidth(current => (current === nextWidth ? current : nextWidth));
}
+ const canCreate =
+ hasPrompt &&
+ Boolean(selectedRepo) &&
+ Boolean(model) &&
+ !attachments.isUploading &&
+ !attachments.hasFailedAttachments;
+ const paperclipDisabled =
+ isCreating || attachments.attachments.length >= AGENT_ATTACHMENT_MAX_FILES;
+
return (
-
+
{
attachments.removeAttachment(id);
}}
+ onRetry={id => {
+ attachments.retryAttachment(id);
+ }}
/>
{
void handleAddAttachment();
}}
- disabled={isCreating || attachments.attachments.length >= AGENT_ATTACHMENT_MAX_FILES}
+ disabled={paperclipDisabled}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
- className="h-9 w-9 items-center justify-center rounded-full active:opacity-70"
+ className={cn(
+ 'h-9 w-9 items-center justify-center rounded-full active:opacity-70',
+ paperclipDisabled && 'opacity-50'
+ )}
accessibilityRole="button"
accessibilityLabel="Add attachment"
+ accessibilityState={{ disabled: paperclipDisabled }}
>
@@ -302,7 +310,10 @@ export default function NewSessionScreen() {
placeholder="What would you like to work on?"
placeholderTextColor={colors.mutedForeground}
multiline
- className="flex-1 px-2 py-2 text-base leading-6 text-foreground"
+ className={cn(
+ 'flex-1 px-2 py-2 text-base leading-6 text-foreground',
+ isCreating && 'opacity-50'
+ )}
style={[
promptInputStyle,
{ height: promptMeasure.height },
@@ -313,77 +324,105 @@ export default function NewSessionScreen() {
onChangeText={text => {
promptRef.current = text;
promptMeasure.setText(text);
+ const nextHasPrompt = text.trim().length > 0;
+ setHasPrompt(current => (current === nextHasPrompt ? current : nextHasPrompt));
}}
onLayout={handlePromptInputLayout}
scrollEnabled={promptMeasure.height >= PROMPT_INPUT_MAX_HEIGHT}
editable={!isCreating}
+ accessibilityState={{ disabled: isCreating }}
autoFocus
/>
-
+ {isModelsError && models.length === 0 ? (
+ void refetchModels()}
+ className="border-t border-border py-4"
+ />
+ ) : (
+
+ )}
Repository
-
- {showGitHubIntegrationPrompt ? (
-
-
- Connect GitHub
-
- Connect GitHub in your browser, then return here to pick a repository.
-
-
-
-
-
-
-
- ) : null}
+ {isReposError && repoData === undefined ? (
+ void refetchRepos()}
+ isRetrying={isRefetchingRepos}
+ />
+ ) : (
+ <>
+
+ {showGitHubIntegrationPrompt ? (
+
+
+ Connect GitHub
+
+ Connect GitHub in your browser, then return here to pick a repository.
+
+
+
+
+
+
+
+ ) : null}
+ >
+ )}
diff --git a/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx
index f4c0f1d4fe..79b1e74421 100644
--- a/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx
+++ b/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx
@@ -1,10 +1,12 @@
import { useFocusEffect, useRouter } from 'expo-router';
import * as Haptics from 'expo-haptics';
-import { Check, Lock, Search, Unlock } from 'lucide-react-native';
+import { Check, Info, Lock, Search, SearchX, Unlock } from 'lucide-react-native';
import { useCallback, useMemo, useRef, useState } from 'react';
import { FlatList, Pressable, TextInput, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { EmptyState } from '@/components/empty-state';
+import { PickerSheet } from '@/components/picker-sheet';
import { Text } from '@/components/ui/text';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { clearRepoPickerBridge, getRepoPickerBridge } from '@/lib/picker-bridge';
@@ -55,35 +57,21 @@ export default function RepoPickerScreen() {
if (!bridge) {
return (
-
- No repositories available
-
+
);
}
return (
- repo.fullName}
- keyboardShouldPersistTaps="handled"
- keyboardDismissMode="on-drag"
- contentContainerStyle={{ paddingBottom: bottom }}
- ListHeaderComponent={
-
-
- Select Repository
-
- Done
-
-
-
+
+ repo.fullName}
+ keyboardShouldPersistTaps="handled"
+ keyboardDismissMode="on-drag"
+ contentContainerStyle={{ paddingBottom: bottom }}
+ ListHeaderComponent={
+
-
- }
- ListEmptyComponent={
-
-
- {search.trim() ? 'No repositories match your search' : 'No repositories available'}
-
-
- }
- renderItem={({ item: repo }) => (
- {
- handleSelect(repo.fullName);
- }}
- accessibilityRole="button"
- accessibilityLabel={repo.fullName}
- >
- {repo.isPrivate ? (
-
- ) : (
-
- )}
-
- {repo.fullName}
-
- {bridge.currentValue === repo.fullName ? (
-
- ) : null}
-
- )}
- />
+ }
+ ListEmptyComponent={
+
+ }
+ renderItem={({ item: repo }) => (
+ {
+ handleSelect(repo.fullName);
+ }}
+ accessibilityRole="button"
+ accessibilityLabel={repo.fullName}
+ >
+ {repo.isPrivate ? (
+
+ ) : (
+
+ )}
+
+ {repo.fullName}
+
+ {bridge.currentValue === repo.fullName ? (
+
+ ) : null}
+
+ )}
+ />
+
);
}
diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/billing.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/billing.tsx
index 9b8e63c1ef..349011f8e9 100644
--- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/billing.tsx
+++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/billing.tsx
@@ -1,8 +1,13 @@
-import { ExternalLink } from 'lucide-react-native';
+import { formatDollars, fromMicrodollars } from '@kilocode/app-shared/utils';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useLocalSearchParams } from 'expo-router';
+import { CreditCard, ExternalLink } from 'lucide-react-native';
import { Linking, ScrollView, View } from 'react-native';
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
-import { useLocalSearchParams } from 'expo-router';
+import { toast } from 'sonner-native';
+import { EmptyState } from '@/components/empty-state';
+import { InstanceContextBoundary } from '@/components/kiloclaw/instance-context-boundary';
import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
import { Button } from '@/components/ui/button';
@@ -13,6 +18,8 @@ import { useInstanceContext } from '@/lib/hooks/use-instance-context';
import { useKiloClawBillingStatus } from '@/lib/hooks/use-kiloclaw-queries';
import { formatBillingDate, formatRemainingDays } from '@/lib/hooks/use-kiloclaw-billing';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { useDetailScreenBottomPadding } from '@/lib/screen-insets';
+import { useTRPC } from '@/lib/trpc';
import { cn } from '@/lib/utils';
function DetailRow({
@@ -30,32 +37,105 @@ function DetailRow({
);
}
+function formatStandardPrice(microdollars: number | null | undefined): string {
+ return microdollars == null
+ ? 'your Standard monthly price'
+ : `${formatDollars(fromMicrodollars(microdollars))}/month`;
+}
+
+/** "Continue month-to-month" CTA shown during a Commit plan's final term. */
+function ContinueMonthToMonthAction({ instanceId }: Readonly<{ instanceId: string }>) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const mutation = useMutation(
+ trpc.kiloclaw.continueCommitAsStandard.mutationOptions({
+ onSuccess: () => {
+ void queryClient.invalidateQueries({
+ queryKey: trpc.kiloclaw.getBillingStatus.queryKey(),
+ });
+ },
+ onError: error => {
+ toast.error(error.message);
+ },
+ })
+ );
+
+ return (
+
+ );
+}
+
+function FinalCommitTermDetails({
+ billing,
+}: Readonly<{
+ billing: NonNullable['data']>;
+}>) {
+ const subscription = billing.subscription;
+ if (!subscription) {
+ return null;
+ }
+ const finalDate = formatBillingDate(
+ subscription.finalCommitEndsAt ?? subscription.currentPeriodEnd
+ );
+ const priceText = formatStandardPrice(subscription.standardContinuationPriceMicrodollars);
+ const instanceId = billing.instance?.id;
+
+ return (
+
+
+
+
+
+
+
+
+ {subscription.standardContinuationScheduled
+ ? `Standard starts on ${finalDate} at ${priceText}.`
+ : `Your final Commit term ends on ${finalDate}. Continue as Standard at ${priceText}, or hosting ends.`}
+
+ {!subscription.standardContinuationScheduled && instanceId ? (
+
+ ) : null}
+
+
+ );
+}
+
function PlanDetails({
billing,
}: Readonly<{
billing: NonNullable['data']>;
}>) {
+ if (billing.subscription?.isFinalCommitTerm) {
+ return ;
+ }
if (billing.subscription) {
const planName =
billing.subscription.plan.charAt(0).toUpperCase() + billing.subscription.plan.slice(1);
+ const cancelling = billing.subscription.cancelAtPeriodEnd;
return (
- {billing.subscription.cancelAtPeriodEnd && (
- <>
-
-
- >
- )}
);
}
@@ -84,22 +164,30 @@ function PlanDetails({
);
}
return (
-
-
- No active plan
-
-
+
);
}
export default function BillingScreen() {
const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>();
- const { isResolved, isOrg } = useInstanceContext(instanceId);
+ const instanceContext = useInstanceContext(instanceId);
+ const isOrg = instanceContext.status === 'ready' && instanceContext.isOrg;
const colors = useThemeColors();
+ const paddingBottom = useDetailScreenBottomPadding();
- const billingQuery = useKiloClawBillingStatus(isResolved && !isOrg);
+ const billingQuery = useKiloClawBillingStatus(instanceContext.status === 'ready' && !isOrg);
const billing = billingQuery.data;
+ if (instanceContext.status === 'error' || instanceContext.status === 'not_found') {
+ return ;
+ }
+
if (isOrg) {
return (
@@ -117,9 +205,25 @@ export default function BillingScreen() {
return (
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -145,7 +249,11 @@ export default function BillingScreen() {
return (
-
+
{/* Plan details */}
diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/changelog.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/changelog.tsx
index 35ac0a6d1e..c2b81794dc 100644
--- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/changelog.tsx
+++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/changelog.tsx
@@ -1,70 +1,112 @@
import { Newspaper } from 'lucide-react-native';
-import { ScrollView, View } from 'react-native';
+import { Alert, ScrollView, View } from 'react-native';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
-import { useLocalSearchParams } from 'expo-router';
+import { type Href, useLocalSearchParams, useRouter } from 'expo-router';
import { EmptyState } from '@/components/empty-state';
import { ChangelogList } from '@/components/kiloclaw/changelog-list';
+import { InstanceContextBoundary } from '@/components/kiloclaw/instance-context-boundary';
import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
-import { useInstanceContext } from '@/lib/hooks/use-instance-context';
-import { useKiloClawChangelog } from '@/lib/hooks/use-kiloclaw-queries';
+import { instanceOrgId, useInstanceContext } from '@/lib/hooks/use-instance-context';
+import { useKiloClawChangelog, useKiloClawMutations } from '@/lib/hooks/use-kiloclaw-queries';
+import { useDetailScreenBottomPadding } from '@/lib/screen-insets';
export default function ChangelogScreen() {
const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>();
- const { organizationId } = useInstanceContext(instanceId);
+ const instanceContext = useInstanceContext(instanceId);
+ const organizationId = instanceOrgId(instanceContext);
const changelogQuery = useKiloClawChangelog(organizationId);
+ const mutations = useKiloClawMutations(organizationId);
+ const router = useRouter();
const entries = changelogQuery.data;
+ const paddingBottom = useDetailScreenBottomPadding();
- function renderContent() {
+ function handleRedeploy() {
+ Alert.alert('Redeploy instance', 'Are you sure you want to redeploy this instance?', [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Redeploy',
+ onPress: () => {
+ mutations.restartMachine.mutate(undefined);
+ },
+ },
+ ]);
+ }
+
+ function handleUpgrade() {
+ router.push(`/(app)/kiloclaw/${instanceId}/settings/version-pin` as Href);
+ }
+
+ if (instanceContext.status === 'error' || instanceContext.status === 'not_found') {
+ return ;
+ }
+
+ function renderBody() {
if (changelogQuery.isPending) {
return (
-
+
);
}
+
if (changelogQuery.isError) {
return (
- {
- void changelogQuery.refetch();
- }}
- />
+
+ {
+ void changelogQuery.refetch();
+ }}
+ />
+
);
}
+
if (!entries || entries.length === 0) {
return (
-
+
+
+
);
}
+
return (
-
-
-
+
+
+
+ Recent updates
+
+
+
+
+
+
);
}
return (
-
-
-
- Recent Updates
-
- {renderContent()}
-
-
+ {renderBody()}
);
}
diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx
index bd92b1d24a..96e936c56d 100644
--- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx
+++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx
@@ -11,24 +11,25 @@ import {
View,
} from 'react-native';
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BillingBanner } from '@/components/kiloclaw/billing-banner';
import {
DangerZone,
DashboardHero,
- ServiceDegradedBanner,
+ DashboardServiceStatus,
+ StatusCardGroupSkeleton,
} from '@/components/kiloclaw/dashboard-parts';
+import { InstanceContextBoundary } from '@/components/kiloclaw/instance-context-boundary';
import { InstanceControls } from '@/components/kiloclaw/instance-controls';
-import { RenameInstanceModal } from '@/components/kiloclaw/rename-instance-modal';
import { SettingsList } from '@/components/kiloclaw/settings-list';
import { StatusCard } from '@/components/kiloclaw/status-card';
import { QueryError } from '@/components/query-error';
+import { RenameModal } from '@/components/rename-modal';
import { ScreenHeader } from '@/components/screen-header';
import { captureEvent, INSTANCE_ACTION_EVENT } from '@/lib/analytics/posthog';
import { ConfigureRow } from '@/components/ui/configure-row';
import { Skeleton } from '@/components/ui/skeleton';
-import { useInstanceContext } from '@/lib/hooks/use-instance-context';
+import { instanceOrgId, useInstanceContext } from '@/lib/hooks/use-instance-context';
import {
useKiloClawBillingStatus,
useKiloClawConfig,
@@ -37,18 +38,22 @@ import {
useKiloClawServiceDegraded,
useKiloClawStatus,
} from '@/lib/hooks/use-kiloclaw-queries';
+import { useManualRefresh } from '@/lib/hooks/use-manual-refresh';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { formatModelName, stripModelPrefix } from '@/lib/model-id';
+import { useDetailScreenBottomPadding } from '@/lib/screen-insets';
export default function DashboardScreen() {
const router = useRouter();
const colors = useThemeColors();
- const { bottom } = useSafeAreaInsets();
+ const paddingBottom = useDetailScreenBottomPadding();
const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>();
- const { organizationId, isResolved, isOrg } = useInstanceContext(instanceId);
+ const instanceContext = useInstanceContext(instanceId);
+ const organizationId = instanceOrgId(instanceContext);
+ const isOrg = instanceContext.status === 'ready' && instanceContext.isOrg;
const statusQuery = useKiloClawStatus(organizationId);
- const isPersonal = isResolved && !isOrg;
+ const isPersonal = instanceContext.status === 'ready' && !isOrg;
const billingQuery = useKiloClawBillingStatus(isPersonal);
const serviceDegradedQuery = useKiloClawServiceDegraded();
const mutations = useKiloClawMutations(organizationId);
@@ -66,29 +71,22 @@ export default function DashboardScreen() {
const isLoading = statusQuery.isPending || (isPersonal && billingQuery.isPending);
const [renameVisible, setRenameVisible] = useState(false);
- const [manualRefreshing, setManualRefreshing] = useState(false);
const refetchStatus = statusQuery.refetch;
const refetchBilling = billingQuery.refetch;
const refetchServiceDegraded = serviceDegradedQuery.refetch;
const refetchGateway = gatewayQuery.refetch;
const refetchConfig = configQuery.refetch;
- const handleRefresh = useCallback(() => {
- void (async () => {
- setManualRefreshing(true);
- try {
- const refreshes = [
- refetchStatus(),
- refetchConfig(),
- refetchServiceDegraded(),
- ...(isRunning ? [refetchGateway()] : []),
- ...(isPersonal ? [refetchBilling()] : []),
- ];
- await Promise.all(refreshes);
- } finally {
- setManualRefreshing(false);
- }
- })();
+ const refetchAll = useCallback(async () => {
+ const refreshes = [
+ refetchStatus(),
+ refetchConfig(),
+ refetchServiceDegraded(),
+ ...(isRunning ? [refetchGateway()] : []),
+ ...(isPersonal ? [refetchBilling()] : []),
+ ];
+ const results = await Promise.all(refreshes);
+ return { isError: results.some(result => result.isError) };
}, [
refetchBilling,
refetchConfig,
@@ -98,17 +96,34 @@ export default function DashboardScreen() {
isPersonal,
isRunning,
]);
+ const [manualRefreshing, handleRefresh] = useManualRefresh(
+ refetchAll,
+ 'Could not refresh. Showing the last known status.'
+ );
+
+ if (instanceContext.status === 'error' || instanceContext.status === 'not_found') {
+ return ;
+ }
if (isLoading) {
return (
-
-
-
-
-
-
+
+
+ {/* Hero */}
+
+
+
+
+
+
+
+ {/* Status card: "Gateway Process" (5 rows) + "Resources" (3 rows) */}
+
+
+
+
@@ -136,7 +151,7 @@ export default function DashboardScreen() {
const handleDestroy = () => {
Alert.alert(
- 'Destroy Instance',
+ 'Destroy instance',
'This will permanently destroy your KiloClaw instance and all its data. This action cannot be undone.',
[
{ text: 'Cancel', style: 'cancel' },
@@ -145,9 +160,16 @@ export default function DashboardScreen() {
style: 'destructive',
onPress: () => {
captureEvent(INSTANCE_ACTION_EVENT, { surface: 'claw', action: 'destroy' });
- mutations.destroy.mutate(undefined);
- router.dismissAll();
- router.replace('/(app)/(tabs)/(0_home)' as Href);
+ // Stay on screen while the mutation is pending — DangerZone shows
+ // its own pending UI — and only navigate away once destruction
+ // actually succeeds. On error the centralized mutation hook
+ // toasts the failure and we stay put with context intact.
+ mutations.destroy.mutate(undefined, {
+ onSuccess: () => {
+ router.dismissAll();
+ router.replace('/(app)/(tabs)/(0_home)' as Href);
+ },
+ });
},
},
]
@@ -162,7 +184,8 @@ export default function DashboardScreen() {
onPress={() => {
setRenameVisible(true);
}}
- hitSlop={8}
+ // 18px icon + 13 slop each side = 44pt minimum touch target
+ hitSlop={13}
accessibilityLabel="Rename instance"
className="active:opacity-70"
>
@@ -173,7 +196,7 @@ export default function DashboardScreen() {
- {isServiceDegraded && (
- {
- void Linking.openURL('https://status.kilo.ai');
- }}
- />
- )}
+ void serviceDegradedQuery.refetch()}
+ onOpenStatusPage={() => {
+ void Linking.openURL('https://status.kilo.ai');
+ }}
+ />
{isPersonal && billing && Platform.OS !== 'ios' ? (
@@ -205,7 +230,7 @@ export default function DashboardScreen() {
) : null}
-
+
+ {/* Gateway/config are optional live detail on top of the essential
+ status fields above — on failure the dashes StatusCard already
+ renders for missing values are indistinguishable from "no
+ data", so call out the failure and offer a retry instead. */}
+ {gatewayQuery.isError || configQuery.isError ? (
+ {
+ void gatewayQuery.refetch();
+ void configQuery.refetch();
+ }}
+ isRetrying={gatewayQuery.isFetching || configQuery.isFetching}
+ className="rounded-2xl border border-border bg-card py-4"
+ />
+ ) : null}
@@ -252,10 +294,12 @@ export default function DashboardScreen() {
{renameVisible && (
- {
- mutations.renameInstance.mutate({ name });
+ {
+ await mutations.renameInstance.mutateAsync({ name });
}}
onClose={() => {
setRenameVisible(false);
diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/channels.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/channels.tsx
index 12c21e5635..be97612cc3 100644
--- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/channels.tsx
+++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/channels.tsx
@@ -4,34 +4,43 @@ import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { useLocalSearchParams } from 'expo-router';
import { EmptyState } from '@/components/empty-state';
+import { InstanceContextBoundary } from '@/components/kiloclaw/instance-context-boundary';
import { SettingsCard } from '@/components/kiloclaw/settings-card';
import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
import { Skeleton } from '@/components/ui/skeleton';
-import { useInstanceContext } from '@/lib/hooks/use-instance-context';
+import { instanceOrgId, useInstanceContext } from '@/lib/hooks/use-instance-context';
import { useKiloClawChannelCatalog, useKiloClawMutations } from '@/lib/hooks/use-kiloclaw-queries';
+import { useDetailScreenBottomPadding } from '@/lib/screen-insets';
export default function ChannelsScreen() {
const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>();
- const { organizationId } = useInstanceContext(instanceId);
+ const instanceContext = useInstanceContext(instanceId);
+ const organizationId = instanceOrgId(instanceContext);
const catalogQuery = useKiloClawChannelCatalog(organizationId);
const mutations = useKiloClawMutations(organizationId);
+ const paddingBottom = useDetailScreenBottomPadding();
const isLoading = catalogQuery.isPending;
- function renderContent() {
+ if (instanceContext.status === 'error' || instanceContext.status === 'not_found') {
+ return ;
+ }
+
+ function renderBody() {
if (isLoading) {
return (
-
+
);
}
+
if (catalogQuery.isError) {
return (
-
+
{
@@ -41,9 +50,10 @@ export default function ChannelsScreen() {
);
}
+
if (catalogQuery.data.length === 0) {
return (
-
+
);
}
- return (
-
- {catalogQuery.data.map(channel => (
-
- ))}
-
- );
- }
- return (
-
-
+ return (
- {renderContent()}
+
+ {catalogQuery.data.map(channel => (
+
+ ))}
+
+ );
+ }
+
+ return (
+
+
+ {renderBody()}
);
}
diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/device-pairing.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/device-pairing.tsx
index 369c55a823..58eb6dc592 100644
--- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/device-pairing.tsx
+++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/device-pairing.tsx
@@ -15,17 +15,19 @@ import { useLocalSearchParams } from 'expo-router';
import { EmptyState } from '@/components/empty-state';
import { CATALOG_ICONS } from '@/components/icons';
+import { InstanceContextBoundary } from '@/components/kiloclaw/instance-context-boundary';
import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
-import { useInstanceContext } from '@/lib/hooks/use-instance-context';
+import { instanceOrgId, useInstanceContext } from '@/lib/hooks/use-instance-context';
import {
useKiloClawDevicePairing,
useKiloClawMutations,
useKiloClawPairing,
} from '@/lib/hooks/use-kiloclaw-queries';
+import { useDetailScreenBottomPadding } from '@/lib/screen-insets';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
const CHANNEL_LABELS: Record = {
@@ -37,12 +39,14 @@ const CHANNEL_LABELS: Record = {
export default function DevicePairingScreen() {
const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>();
- const { organizationId } = useInstanceContext(instanceId);
+ const instanceContext = useInstanceContext(instanceId);
+ const organizationId = instanceOrgId(instanceContext);
const colors = useThemeColors();
const queryClient = useQueryClient();
const pairingQuery = useKiloClawPairing(organizationId);
const devicePairingQuery = useKiloClawDevicePairing(organizationId);
const mutations = useKiloClawMutations(organizationId);
+ const paddingBottom = useDetailScreenBottomPadding();
const isLoading = pairingQuery.isPending || devicePairingQuery.isPending;
@@ -68,7 +72,11 @@ export default function DevicePairingScreen() {
onPress={() => {
void handleRefresh();
}}
- className="p-2"
+ className="p-2 active:opacity-70"
+ // 18px icon + p-2 = 34pt; slop brings the target to 44pt.
+ hitSlop={5}
+ accessibilityRole="button"
+ accessibilityLabel="Refresh pairing requests"
>
@@ -76,10 +84,14 @@ export default function DevicePairingScreen() {
);
+ if (instanceContext.status === 'error' || instanceContext.status === 'not_found') {
+ return ;
+ }
+
if (isLoading) {
return (
-
+
@@ -92,7 +104,7 @@ export default function DevicePairingScreen() {
if (pairingQuery.isError || devicePairingQuery.isError) {
return (
-
+
-
+
-
-
+
+
{channelRequests.length > 0 && (
- Channel Requests
+ Channel requests
{channelRequests.map((request, index) => {
const ChannelIcon = CATALOG_ICONS[request.channel];
+ const isThisPending =
+ mutations.approvePairingRequest.isPending &&
+ mutations.approvePairingRequest.variables.channel === request.channel &&
+ mutations.approvePairingRequest.variables.code === request.code;
return (
{index > 0 && }
@@ -192,6 +212,8 @@ export default function DevicePairingScreen() {
)}
@@ -155,7 +227,7 @@ export default function GoogleScreen() {
- Gmail Notifications
+ Gmail notifications
-
-
+
+ {!mutations.disconnectGoogle.isPending && }
Disconnect Google Account
diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/model-list.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/model-list.tsx
index 213c05dd4c..46c9327de4 100644
--- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/model-list.tsx
+++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/model-list.tsx
@@ -1,16 +1,26 @@
import { useQuery } from '@tanstack/react-query';
-import { Check, Eye } from 'lucide-react-native';
-import { useCallback, useState } from 'react';
-import { FlatList, Pressable, TextInput, View } from 'react-native';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { Check, Eye, Search } from 'lucide-react-native';
+import { useCallback, useMemo, useRef, useState } from 'react';
+import {
+ ActivityIndicator,
+ FlatList,
+ Pressable,
+ TextInput,
+ View,
+ type ViewStyle,
+} from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
+import { EmptyState } from '@/components/empty-state';
+import { InstanceContextBoundary } from '@/components/kiloclaw/instance-context-boundary';
import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
+import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
-import { useInstanceContext } from '@/lib/hooks/use-instance-context';
+import { instanceOrgId, useInstanceContext } from '@/lib/hooks/use-instance-context';
import { useKiloClawConfig, useKiloClawMutations } from '@/lib/hooks/use-kiloclaw-queries';
+import { useDetailScreenBottomPadding } from '@/lib/screen-insets';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { addModelPrefix, stripModelPrefix } from '@/lib/model-id';
import { useTRPC } from '@/lib/trpc';
@@ -24,24 +34,40 @@ type ModelItem = {
export default function ModelListScreen() {
const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>();
- const { organizationId } = useInstanceContext(instanceId);
+ const instanceContext = useInstanceContext(instanceId);
+ const organizationId = instanceOrgId(instanceContext);
const router = useRouter();
const colors = useThemeColors();
- const { bottom } = useSafeAreaInsets();
+ const paddingBottom = useDetailScreenBottomPadding();
const trpc = useTRPC();
const [searchFilter, setSearchFilter] = useState('');
+ const searchInputRef = useRef(null);
- const { data: config } = useKiloClawConfig(organizationId);
+ const handleClearSearch = useCallback(() => {
+ setSearchFilter('');
+ searchInputRef.current?.clear();
+ }, []);
+
+ const configQuery = useKiloClawConfig(organizationId);
+ const config = configQuery.data;
const mutations = useKiloClawMutations(organizationId);
const currentModel = stripModelPrefix(config?.kilocodeDefaultModel);
const {
data: models,
- isLoading,
- isError,
+ isLoading: isModelsLoading,
+ isError: isModelsError,
refetch,
} = useQuery(trpc.models.list.queryOptions(undefined, { staleTime: 5 * 60_000 }));
+ // Instance context resolves organizationId — until it's ready, updateModel would
+ // mutate with organizationId undefined (PERSONAL config) instead of the org's.
+ const isLoading = isModelsLoading || instanceContext.status === 'loading';
+ // Without a known current model, rows would render selectable with no
+ // indication of what's actually selected — treat a config load failure
+ // the same as a models load failure.
+ const isError = isModelsError || configQuery.isError;
+
const filtered = (models ?? []).filter((m: ModelItem) => {
if (!searchFilter) {
return true;
@@ -53,6 +79,11 @@ export default function ModelListScreen() {
const preferred = filtered.filter(m => m.isPreferred);
const rest = filtered.filter(m => !m.isPreferred);
+ const listContentContainerStyle = useMemo(
+ () => ({ paddingBottom, flexGrow: 1 }) satisfies ViewStyle,
+ [paddingBottom]
+ );
+
const handleSelect = useCallback(
(modelId: string) => {
mutations.updateModel.mutate(
@@ -67,9 +98,14 @@ export default function ModelListScreen() {
[mutations.updateModel, router]
);
+ const pendingModelId = mutations.updateModel.isPending
+ ? stripModelPrefix(mutations.updateModel.variables.kilocodeDefaultModel)
+ : undefined;
+
const renderItem = useCallback(
({ item }: { item: ModelItem }) => {
const selected = currentModel === item.id;
+ const isRowPending = mutations.updateModel.isPending && pendingModelId === item.id;
return (
{item.name}
{item.id}
- {item.supportsVision && }
- {selected && }
+ {isRowPending ? (
+
+ ) : (
+ <>
+ {item.supportsVision && }
+ {selected && }
+ >
+ )}
);
},
@@ -91,6 +134,7 @@ export default function ModelListScreen() {
currentModel,
handleSelect,
mutations.updateModel.isPending,
+ pendingModelId,
colors.mutedForeground,
colors.primary,
]
@@ -105,17 +149,22 @@ export default function ModelListScreen() {
: []),
...(rest.length > 0
? [
- { type: 'header' as const, title: 'All Models' },
+ { type: 'header' as const, title: 'All models' },
...rest.map(m => ({ type: 'model' as const, model: m })),
]
: []),
];
+ if (instanceContext.status === 'error' || instanceContext.status === 'not_found') {
+ return ;
+ }
+
return (
-
+
- void refetch()} />
+ {
+ void refetch();
+ void configQuery.refetch();
+ }}
+ />
)}
{!isLoading && !isError && (
@@ -143,7 +198,26 @@ export default function ModelListScreen() {
keyExtractor={(item, index) =>
item.type === 'header' ? `header-${item.title}` : `model-${item.model.id}-${index}`
}
- contentContainerStyle={{ paddingBottom: Math.max(bottom, 16) }}
+ contentContainerStyle={listContentContainerStyle}
+ ListEmptyComponent={
+
+ Clear search
+
+ ) : undefined
+ }
+ />
+ }
renderItem={({ item }) => {
if (item.type === 'header') {
return (
diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/model.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/model.tsx
index 0df41c5872..ff0c60fe89 100644
--- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/model.tsx
+++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/model.tsx
@@ -1,13 +1,25 @@
import { ScrollView, View } from 'react-native';
+import { useLocalSearchParams } from 'expo-router';
+import { InstanceContextBoundary } from '@/components/kiloclaw/instance-context-boundary';
import { ModelPicker } from '@/components/kiloclaw/model-picker';
import { ScreenHeader } from '@/components/screen-header';
+import { useInstanceContext } from '@/lib/hooks/use-instance-context';
+import { useDetailScreenBottomPadding } from '@/lib/screen-insets';
export default function ModelSettingsScreen() {
+ const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>();
+ const instanceContext = useInstanceContext(instanceId);
+ const paddingBottom = useDetailScreenBottomPadding();
+
+ if (instanceContext.status === 'error' || instanceContext.status === 'not_found') {
+ return ;
+ }
+
return (
-
+
diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/secrets.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/secrets.tsx
index 884389adc4..45586e204d 100644
--- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/secrets.tsx
+++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/secrets.tsx
@@ -4,24 +4,32 @@ import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { useLocalSearchParams } from 'expo-router';
import { EmptyState } from '@/components/empty-state';
+import { InstanceContextBoundary } from '@/components/kiloclaw/instance-context-boundary';
import { SettingsCard } from '@/components/kiloclaw/settings-card';
import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
import { Skeleton } from '@/components/ui/skeleton';
-import { useInstanceContext } from '@/lib/hooks/use-instance-context';
+import { instanceOrgId, useInstanceContext } from '@/lib/hooks/use-instance-context';
import { useKiloClawMutations, useKiloClawSecretCatalog } from '@/lib/hooks/use-kiloclaw-queries';
+import { useDetailScreenBottomPadding } from '@/lib/screen-insets';
export default function SecretsScreen() {
const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>();
- const { organizationId } = useInstanceContext(instanceId);
+ const instanceContext = useInstanceContext(instanceId);
+ const organizationId = instanceOrgId(instanceContext);
const mutations = useKiloClawMutations(organizationId);
const catalogQuery = useKiloClawSecretCatalog(organizationId);
+ const paddingBottom = useDetailScreenBottomPadding();
const isLoading = catalogQuery.isPending;
- function renderContent() {
+ if (instanceContext.status === 'error' || instanceContext.status === 'not_found') {
+ return ;
+ }
+
+ function renderBody() {
if (isLoading) {
return (
-
+
@@ -29,9 +37,10 @@ export default function SecretsScreen() {
);
}
+
if (catalogQuery.isError) {
return (
-
+
{
@@ -41,9 +50,10 @@ export default function SecretsScreen() {
);
}
+
if (catalogQuery.data.length === 0) {
return (
-
+
);
}
- return (
-
- {catalogQuery.data.map(secret => (
-
- ))}
-
- );
- }
- return (
-
-
+ return (
- {renderContent()}
+
+ {catalogQuery.data.map(secret => (
+
+ ))}
+
+ );
+ }
+
+ return (
+
+
+ {renderBody()}
);
}
diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/version-pin.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/version-pin.tsx
index fdfa129c25..0333ddaa19 100644
--- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/version-pin.tsx
+++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/version-pin.tsx
@@ -1,46 +1,58 @@
-import { Check } from 'lucide-react-native';
+import { PackageSearch } from 'lucide-react-native';
import { useRef, useState } from 'react';
-import { Alert, FlatList, TextInput, View } from 'react-native';
+import { Alert, FlatList, View } from 'react-native';
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
import { useLocalSearchParams } from 'expo-router';
+import { EmptyState } from '@/components/empty-state';
+import { InstanceContextBoundary } from '@/components/kiloclaw/instance-context-boundary';
+import { type VersionItem, VersionPinRow } from '@/components/kiloclaw/version-pin-row';
+import { VersionPinStatusCard } from '@/components/kiloclaw/version-pin-status-card';
import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
-import { useInstanceContext } from '@/lib/hooks/use-instance-context';
+import { instanceOrgId, useInstanceContext } from '@/lib/hooks/use-instance-context';
import {
useKiloClawAvailableVersions,
useKiloClawLatestVersion,
useKiloClawMutations,
useKiloClawMyPin,
} from '@/lib/hooks/use-kiloclaw-queries';
-import { useThemeColors } from '@/lib/hooks/use-theme-colors';
-import { parseTimestamp, timeAgo } from '@/lib/utils';
+import { useDetailScreenBottomPadding } from '@/lib/screen-insets';
-type VersionItem = NonNullable<
- ReturnType['data']
->['items'][number];
+const PAGE_SIZE = 25;
+// Server caps `limit` at 100 (kiloclaw-router.ts listAvailableVersions) — never send more.
+const MAX_LIMIT = 100;
export default function VersionPinScreen() {
const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>();
- const { organizationId } = useInstanceContext(instanceId);
- const colors = useThemeColors();
+ const instanceContext = useInstanceContext(instanceId);
+ const organizationId = instanceOrgId(instanceContext);
const myPinQuery = useKiloClawMyPin(organizationId);
const latestVersionQuery = useKiloClawLatestVersion();
- const availableVersionsQuery = useKiloClawAvailableVersions(organizationId);
+ const [limit, setLimit] = useState(PAGE_SIZE);
+ const availableVersionsQuery = useKiloClawAvailableVersions(organizationId, 0, limit);
const mutations = useKiloClawMutations(organizationId);
+ const paddingBottom = useDetailScreenBottomPadding();
const pendingReasonRef = useRef('');
const [pendingItem, setPendingItem] = useState();
const flatListRef = useRef>(null);
const isLoading = myPinQuery.isPending || latestVersionQuery.isPending;
+ // Only one pin/unpin mutation should ever be in flight at a time — while
+ // either is pending, every pin control is disabled so they can't race.
+ const isPinMutating = mutations.setMyPin.isPending || mutations.removeMyPin.isPending;
+
+ if (instanceContext.status === 'error' || instanceContext.status === 'not_found') {
+ return ;
+ }
if (isLoading) {
return (
-
+
@@ -53,10 +65,18 @@ export default function VersionPinScreen() {
);
}
- if (myPinQuery.isError || latestVersionQuery.isError || availableVersionsQuery.isError) {
+ // Only a genuine initial-load failure (no cached data yet) is a hard
+ // error. A background refetch failure (e.g. a failed Load More page)
+ // must not blank out already-rendered versions or pin status. Compare
+ // against undefined: getMyPin legitimately resolves to null (unpinned).
+ if (
+ (myPinQuery.isError && myPinQuery.data === undefined) ||
+ (latestVersionQuery.isError && latestVersionQuery.data === undefined) ||
+ (availableVersionsQuery.isError && availableVersionsQuery.data === undefined)
+ ) {
return (
-
+
= MAX_LIMIT;
+ const hasMoreVersions = pagination != null && versions.length < pagination.totalCount;
+ const versionsPageFailed = availableVersionsQuery.isError && versions.length > 0;
+ const isFetchingMoreVersions =
+ availableVersionsQuery.isFetching && !availableVersionsQuery.isPending;
const isPinnedByAdmin = myPin != null && !myPin.pinnedBySelf;
function handleUnpin() {
- Alert.alert('Unpin Version', 'Switch back to the latest available version?', [
+ Alert.alert('Unpin version', 'Switch back to the latest available version?', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Unpin',
@@ -130,137 +156,112 @@ export default function VersionPinScreen() {
function renderVersionItem({ item }: { item: VersionItem }) {
const isPinned = myPin?.image_tag === item.image_tag;
- const publishedAgo = item.published_at ? timeAgo(parseTimestamp(item.published_at)) : undefined;
const isLatest = latestVersion?.imageTag === item.image_tag;
- const showVariant = item.variant && item.variant !== 'default';
- const isPending = pendingItem?.image_tag === item.image_tag;
+ const isDraftOpen = pendingItem?.image_tag === item.image_tag;
+ const isConfirmingThis = isDraftOpen && mutations.setMyPin.isPending;
return (
-
-
-
-
- {item.openclaw_version}
- {isLatest && (
-
- latest
-
- )}
-
- {Boolean(publishedAgo ?? showVariant) && (
-
- {[publishedAgo, showVariant ? item.variant : null].filter(Boolean).join(' · ')}
-
- )}
-
- {isPinned ? (
-
- ) : (
- {
- if (isPending) {
- cancelPin();
- } else {
- handlePin(item);
- }
- }}
- >
- {isPending ? 'Cancel' : 'Pin'}
-
- )}
-
- {isPending && (
-
-
- Reason (optional)
- {
- if (val.length <= 500) {
- pendingReasonRef.current = val;
- }
- }}
- autoCapitalize="sentences"
- autoCorrect
- multiline
- maxLength={500}
- />
-
-
- Confirm Pin
-
-
-
- )}
-
+ {
+ if (isDraftOpen) {
+ cancelPin();
+ } else {
+ handlePin(item);
+ }
+ }}
+ onFocusReason={scrollToPendingItem}
+ onReasonChange={val => {
+ pendingReasonRef.current = val;
+ }}
+ onConfirm={confirmPin}
+ />
);
}
+ function renderFooter() {
+ if (versionsPageFailed) {
+ return (
+
+
+ Could not load more versions
+
+ {
+ void availableVersionsQuery.refetch();
+ }}
+ >
+ Retry
+
+
+ );
+ }
+ if (hasMoreVersions && !isAtLimitCap) {
+ return (
+
+ {
+ setLimit(l => Math.min(l + PAGE_SIZE, MAX_LIMIT));
+ }}
+ >
+ Load more versions
+
+
+ );
+ }
+ if (hasMoreVersions && isAtLimitCap) {
+ return (
+
+
+ Showing latest 100 versions
+
+
+ );
+ }
+ return null;
+ }
+
return (
-
+
item.image_tag}
renderItem={renderVersionItem}
- contentContainerClassName="px-4 py-4 gap-4"
+ contentContainerClassName="px-4 pt-4 gap-4"
+ contentContainerStyle={{ paddingBottom }}
automaticallyAdjustKeyboardInsets
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
ListHeaderComponent={
-
- {myPin ? (
- <>
-
-
-
- Pinned to {myPin.openclaw_version ?? myPin.image_tag}
-
- {myPin.reason && (
-
- {myPin.reason}
-
- )}
-
- {!isPinnedByAdmin && (
-
- Unpin
-
- )}
-
- {isPinnedByAdmin && (
-
- Pinned by admin — contact your admin to change.
-
- )}
- >
- ) : (
-
-
-
- Following latest
-
-
- {latestVersion && (
-
- {latestVersion.openclawVersion}
-
- )}
-
- )}
-
+
{versions.length > 0 && (
- Available Versions
+ Available versions
)}
@@ -269,8 +270,17 @@ export default function VersionPinScreen() {
ListEmptyComponent={
availableVersionsQuery.isPending ? (
- ) : undefined
+ ) : (
+
+ )
}
+ ListFooterComponent={renderFooter()}
className="rounded-lg bg-secondary"
/>
diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx
index 6db22d1a31..9f674a3042 100644
--- a/apps/mobile/src/app/_layout.tsx
+++ b/apps/mobile/src/app/_layout.tsx
@@ -40,30 +40,41 @@ import {
setupNotificationResponseHandler,
} from '@/lib/notifications';
import { resolvePendingNotificationNavigation } from '@/lib/pending-notification-navigation';
+import { sentryOptionsForConsent } from '@/lib/sentry-consent';
+import { useSentryConsentSync } from '@/lib/hooks/use-sentry-consent-sync';
const navigationIntegration = Sentry.reactNavigationIntegration({
enableTimeToInitialDisplay: !isRunningInExpoGo(),
});
-Sentry.init({
- dsn: 'https://618cf025f1c6bdea8043fcd80668fe6b@o4509356317474816.ingest.us.sentry.io/4511110711279616',
+// Session replay, screenshots, and view-hierarchy capture are gated on
+// stored consent (see src/lib/sentry-consent.ts) — the consent copy only
+// promises anonymous performance/crash data. The RN SDK reads all of these
+// options only at Sentry.init() time (Mobile Replay has no runtime
+// start/stop API in 7.x), so `consented` starts `false` and every consent
+// transition goes through reinitSentryForConsent, which awaits
+// Sentry.close() first — the only way to stop an in-flight native replay
+// recording and dispose the previous client — before calling this again.
+function initSentry(consented: boolean) {
+ Sentry.init({
+ dsn: 'https://618cf025f1c6bdea8043fcd80668fe6b@o4509356317474816.ingest.us.sentry.io/4511110711279616',
- enabled: true,
+ enabled: true,
- sendDefaultPii: false,
+ sendDefaultPii: false,
- enableLogs: true,
- tracesSampleRate: 0,
- replaysSessionSampleRate: 0.1,
- replaysOnErrorSampleRate: 1,
- attachScreenshot: true,
- attachViewHierarchy: true,
+ enableLogs: true,
+ tracesSampleRate: 0,
+ ...sentryOptionsForConsent(consented),
- integrations: [Sentry.mobileReplayIntegration(), navigationIntegration],
- enableNativeFramesTracking: false,
+ integrations: [Sentry.mobileReplayIntegration(), navigationIntegration],
+ enableNativeFramesTracking: false,
- spotlight: __DEV__,
-});
+ spotlight: __DEV__,
+ });
+}
+
+initSentry(false);
void SplashScreen.preventAutoHideAsync();
setupNotificationHandler();
@@ -98,6 +109,8 @@ function RootLayoutNav() {
}
}, [fontsError]);
+ useSentryConsentSync(consentChecked && !needsConsent, initSentry);
+
const fontsReady = fontsLoaded || fontsError !== null;
const isLoading = authLoading || updateChecking || !fontsReady;
const inAuthGroup = segments[0] === '(auth)';
diff --git a/apps/mobile/src/app/force-update.tsx b/apps/mobile/src/app/force-update.tsx
index ff61a5deb1..9083a665b9 100644
--- a/apps/mobile/src/app/force-update.tsx
+++ b/apps/mobile/src/app/force-update.tsx
@@ -1,28 +1 @@
-import { Download } from 'lucide-react-native';
-import { Linking, Platform, View } from 'react-native';
-
-import { Button } from '@/components/ui/button';
-import { Text } from '@/components/ui/text';
-import { useThemeColors } from '@/lib/hooks/use-theme-colors';
-
-const STORE_URL =
- Platform.OS === 'ios'
- ? 'https://apps.apple.com/app/id6761193135'
- : 'https://play.google.com/store/apps/details?id=com.kilocode.kiloapp';
-
-export default function ForceUpdateScreen() {
- const colors = useThemeColors();
-
- return (
-
-
- Update Required
-
- A new version of Kilo is available. Please update to continue.
-
- void Linking.openURL(STORE_URL)}>
- Update Now
-
-
- );
-}
+export { ForceUpdateScreen as default } from '@/components/force-update-screen';
diff --git a/apps/mobile/src/components/add-credits-row.tsx b/apps/mobile/src/components/add-credits-row.tsx
new file mode 100644
index 0000000000..795db9c795
--- /dev/null
+++ b/apps/mobile/src/components/add-credits-row.tsx
@@ -0,0 +1,31 @@
+import { View } from 'react-native';
+
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { openExternalUrl } from '@/lib/external-link';
+import { cn } from '@/lib/utils';
+
+type AddCreditsRowProps = Readonly<{
+ url: string;
+ className?: string;
+}>;
+
+/** Zero-balance CTA row: muted copy + an "Add credits" button to the web billing page. */
+export function AddCreditsRow({ url, className }: AddCreditsRowProps) {
+ return (
+
+
+ Add credits to keep usage running.
+
+ {
+ void openExternalUrl(url, { label: 'billing page' });
+ }}
+ >
+ Add credits
+
+
+ );
+}
diff --git a/apps/mobile/src/components/agents/attachment-picker.ts b/apps/mobile/src/components/agents/attachment-picker.ts
index 814ccb3da4..9fedefc37c 100644
--- a/apps/mobile/src/components/agents/attachment-picker.ts
+++ b/apps/mobile/src/components/agents/attachment-picker.ts
@@ -9,7 +9,6 @@ import {
type AgentAttachmentExtension,
} from '@/lib/agent-attachments/constants';
import { type AgentAttachmentCandidate } from '@/lib/agent-attachments/use-agent-attachment-upload';
-import { classifyAttachment } from '@/lib/agent-attachments/validate';
const IMAGE_PICKER_OPTIONS = {
mediaTypes: ['images'],
@@ -73,10 +72,7 @@ async function pickAgentCameraImage(): Promise {
if (result.canceled) {
return [];
}
- return result.assets.map(normalizeImageAsset).filter(asset => {
- const classified = classifyAttachment(asset);
- return classified.ok;
- });
+ return result.assets.map(normalizeImageAsset);
}
async function pickAgentLibraryImages(): Promise {
@@ -87,10 +83,7 @@ async function pickAgentLibraryImages(): Promise {
if (result.canceled) {
return [];
}
- return result.assets.map(normalizeImageAsset).filter(asset => {
- const classified = classifyAttachment(asset);
- return classified.ok;
- });
+ return result.assets.map(normalizeImageAsset);
}
async function pickAgentDocuments(): Promise {
@@ -102,10 +95,7 @@ async function pickAgentDocuments(): Promise {
if (result.canceled) {
return [];
}
- return result.assets.map(normalizeDocumentAsset).filter(asset => {
- const classified = classifyAttachment(asset);
- return classified.ok;
- });
+ return result.assets.map(normalizeDocumentAsset);
}
type AttachmentSource = 'camera' | 'library' | 'files';
diff --git a/apps/mobile/src/components/agents/attachment-preview-strip.tsx b/apps/mobile/src/components/agents/attachment-preview-strip.tsx
index 9f4f2b1ee5..c07fcff51a 100644
--- a/apps/mobile/src/components/agents/attachment-preview-strip.tsx
+++ b/apps/mobile/src/components/agents/attachment-preview-strip.tsx
@@ -11,6 +11,7 @@ import { type AgentAttachment } from '@/lib/agent-attachments/use-agent-attachme
type Props = {
attachments: AgentAttachment[];
onRemove: (id: string) => void;
+ onRetry: (id: string) => void;
};
const REMOVE_HIT_SLOP = { top: 8, bottom: 8, left: 8, right: 8 } as const;
@@ -18,9 +19,11 @@ const REMOVE_HIT_SLOP = { top: 8, bottom: 8, left: 8, right: 8 } as const;
function AttachmentChip({
attachment,
onRemove,
+ onRetry,
}: {
attachment: AgentAttachment;
onRemove: () => void;
+ onRetry: () => void;
}) {
const colors = useThemeColors();
const isImage = attachment.kind === 'image';
@@ -28,13 +31,20 @@ function AttachmentChip({
const isErrored = attachment.status === 'error';
return (
-
{isImage ? (
) : null}
+ {isImage && isErrored ? (
+
+
+
+ ) : null}
+
-
+
);
}
-export function AttachmentPreviewStrip({ attachments, onRemove }: Readonly) {
+export function AttachmentPreviewStrip({ attachments, onRemove, onRetry }: Readonly) {
if (attachments.length === 0) {
return null;
}
@@ -99,6 +115,9 @@ export function AttachmentPreviewStrip({ attachments, onRemove }: Readonly {
onRemove(attachment.id);
}}
+ onRetry={() => {
+ onRetry(attachment.id);
+ }}
/>
))}
diff --git a/apps/mobile/src/components/agents/chat-composer.tsx b/apps/mobile/src/components/agents/chat-composer.tsx
index 56a9e7bee3..7df0ea92e3 100644
--- a/apps/mobile/src/components/agents/chat-composer.tsx
+++ b/apps/mobile/src/components/agents/chat-composer.tsx
@@ -3,6 +3,7 @@ import { useActionSheet } from '@expo/react-native-action-sheet';
import { ArrowUp, Paperclip, Square } from 'lucide-react-native';
import { useCallback, useRef, useState } from 'react';
import {
+ ActivityIndicator,
Keyboard,
type LayoutChangeEvent,
Pressable,
@@ -26,6 +27,7 @@ import {
} from '@/lib/agent-attachments/use-agent-attachment-upload';
import { type ModelOption } from '@/lib/hooks/use-available-models';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { cn } from '@/lib/utils';
const TEXT_INPUT_MAX_LINES = 5;
const TEXT_INPUT_LINE_HEIGHT = 20;
@@ -76,6 +78,7 @@ export function ChatComposer({
const [hasText, setHasText] = useState(false);
const [inputWidth, setInputWidth] = useState(0);
const [isFocused, setIsFocused] = useState(false);
+ const [isSending, setIsSending] = useState(false);
const upload = useAgentAttachmentUpload({ organizationId });
const measure = useTextHeight({
@@ -88,9 +91,13 @@ export function ChatComposer({
});
// The backend requires a non-empty prompt even when attachments are present.
- const canSend = hasText && !disabled && !isStreaming;
+ const canSend = hasText && !disabled && !isStreaming && !isSending;
const showToolbar = isFocused || hasText || upload.attachments.length > 0;
- const toolbarDisabled = disabled || isStreaming;
+ // isSending locks the input and attachment controls too — otherwise text or
+ // attachments added while the send is in flight get wiped by the success path.
+ const toolbarDisabled = disabled || isStreaming || isSending;
+ const paperclipDisabled =
+ toolbarDisabled || upload.attachments.length >= AGENT_ATTACHMENT_MAX_FILES;
function handleChangeText(value: string) {
textRef.current = value;
@@ -98,7 +105,7 @@ export function ChatComposer({
setHasText(value.trim().length > 0);
}
- function handleSend() {
+ async function handleSend() {
const trimmed = textRef.current.trim();
if (!trimmed || !canSend) {
return;
@@ -107,6 +114,10 @@ export function ChatComposer({
toast.error('Wait for attachments to finish uploading.');
return;
}
+ if (upload.hasFailedAttachments) {
+ toast.error('Remove or retry failed attachments first.');
+ return;
+ }
if (trimmed.startsWith('/') && upload.attachments.length > 0) {
toast.error('Attachments cannot be sent with slash commands.');
return;
@@ -114,13 +125,23 @@ export function ChatComposer({
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const payload = upload.toWirePayload();
- void onSend(trimmed, payload);
- textRef.current = '';
- setHasText(false);
- measure.reset();
- inputRef.current?.clear();
- upload.reset();
- Keyboard.dismiss();
+ setIsSending(true);
+ try {
+ // Only clear the draft once the send actually succeeds — a failed
+ // send must leave the text and attachments exactly as the user left
+ // them (the parent already surfaces the error toast).
+ await onSend(trimmed, payload);
+ textRef.current = '';
+ setHasText(false);
+ measure.reset();
+ inputRef.current?.clear();
+ upload.reset();
+ Keyboard.dismiss();
+ } catch {
+ // Draft preserved; error already surfaced by the caller.
+ } finally {
+ setIsSending(false);
+ }
}
function handleStop() {
@@ -133,7 +154,7 @@ export function ChatComposer({
setInputWidth(current => (current === nextWidth ? current : nextWidth));
}
- const { addCandidates, removeAttachment } = upload;
+ const { addCandidates, removeAttachment, retryAttachment } = upload;
const handleAddAttachment = useCallback(async () => {
addCandidates(await pickAgentAttachments(showActionSheetWithOptions));
@@ -170,7 +191,11 @@ export function ChatComposer({
) : null}
{attachmentsEnabled ? (
-
+
) : null}
@@ -179,18 +204,25 @@ export function ChatComposer({
onPress={() => {
void handleAddAttachment();
}}
- disabled={toolbarDisabled || upload.attachments.length >= AGENT_ATTACHMENT_MAX_FILES}
+ disabled={paperclipDisabled}
hitSlop={PAPERCLIP_HIT_SLOP}
- className="h-8 w-8 items-center justify-center rounded-full active:opacity-70"
+ className={cn(
+ 'h-8 w-8 items-center justify-center rounded-full active:opacity-70',
+ paperclipDisabled && 'opacity-50'
+ )}
accessibilityRole="button"
accessibilityLabel="Add attachment"
+ accessibilityState={{ disabled: paperclipDisabled }}
>
) : null}
= TEXT_INPUT_MAX_HEIGHT}
editable={!toolbarDisabled}
+ accessibilityState={{ disabled: toolbarDisabled }}
returnKeyType="default"
submitBehavior="newline"
autoCapitalize="sentences"
@@ -224,27 +257,36 @@ export function ChatComposer({
accessibilityRole="button"
accessibilityLabel="Stop generating"
accessibilityState={{ disabled }}
- className="h-8 w-8 items-center justify-center rounded-full bg-neutral-400 active:opacity-70 dark:bg-neutral-500"
+ className={cn(
+ 'h-8 w-8 items-center justify-center rounded-full bg-neutral-400 active:opacity-70 dark:bg-neutral-500',
+ disabled && 'opacity-50'
+ )}
>
) : (
{
+ void handleSend();
+ }}
disabled={!canSend}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel="Send message"
- accessibilityState={{ disabled: !canSend }}
+ accessibilityState={{ disabled: !canSend, busy: isSending }}
className={`h-8 w-8 items-center justify-center rounded-full active:opacity-70 ${
canSend ? 'bg-accent-soft' : 'bg-muted'
}`}
>
-
+ {isSending ? (
+
+ ) : (
+
+ )}
)}
diff --git a/apps/mobile/src/components/agents/chat-toolbar.tsx b/apps/mobile/src/components/agents/chat-toolbar.tsx
index 95154ac37f..977e4e4bb3 100644
--- a/apps/mobile/src/components/agents/chat-toolbar.tsx
+++ b/apps/mobile/src/components/agents/chat-toolbar.tsx
@@ -19,6 +19,7 @@ type ChatToolbarProps = {
modelOptions: ModelOption[];
onModelSelect: (modelId: string, variant: string) => void;
disabled?: boolean;
+ isLoadingModels?: boolean;
order?: ChatToolbarOrder;
className?: string;
showReasoningSettings?: boolean;
@@ -32,6 +33,7 @@ export function ChatToolbar({
modelOptions,
onModelSelect,
disabled = false,
+ isLoadingModels = false,
order = 'mode-first',
className,
showReasoningSettings = true,
@@ -46,6 +48,7 @@ export function ChatToolbar({
options={modelOptions}
onSelect={onModelSelect}
disabled={disabled}
+ isLoading={isLoadingModels}
/>
);
@@ -69,6 +72,7 @@ export function ChatToolbar({
className="h-8 w-8 items-center justify-center rounded-full active:opacity-70"
accessibilityRole="button"
accessibilityLabel="Reasoning settings"
+ accessibilityState={{ disabled }}
>
diff --git a/apps/mobile/src/components/agents/child-session-section.tsx b/apps/mobile/src/components/agents/child-session-section.tsx
index d424bd0ec0..7808d571a7 100644
--- a/apps/mobile/src/components/agents/child-session-section.tsx
+++ b/apps/mobile/src/components/agents/child-session-section.tsx
@@ -4,6 +4,7 @@ import { Bot, ChevronRight, Loader2 } from 'lucide-react-native';
import Animated, { FadeIn, LinearTransition } from 'react-native-reanimated';
import { type Part, type StoredMessage, type ToolPart } from 'cloud-agent-sdk';
+import { SpinningIcon } from '@/components/ui/spinning-icon';
import { Text } from '@/components/ui/text';
import { type ThemeColors, useThemeColors } from '@/lib/hooks/use-theme-colors';
@@ -143,7 +144,7 @@ export function ChildSessionSection({
/>
{isRunning ? (
-
+
) : (
)}
@@ -278,7 +279,7 @@ function getStatusBorderColor(status: string, colors: ThemeColors): string {
if (status === 'completed') {
return colors.good;
}
- return colors.agentSky;
+ return colors.info;
}
function StatusBadge({ status }: Readonly<{ status: string }>) {
@@ -294,22 +295,22 @@ function StatusBadge({ status }: Readonly<{ status: string }>) {
function getStatusBgClass(status: string): string {
if (status === 'completed') {
- return 'bg-green-100 dark:bg-green-900';
+ return 'bg-good-tile-bg';
}
if (status === 'error') {
- return 'bg-red-100 dark:bg-red-900';
+ return 'bg-danger-tile-bg';
}
- return 'bg-blue-100 dark:bg-blue-900';
+ return 'bg-info-tile-bg';
}
function getStatusTextClass(status: string): string {
if (status === 'completed') {
- return 'text-green-700 dark:text-green-300';
+ return 'text-good';
}
if (status === 'error') {
- return 'text-red-700 dark:text-red-300';
+ return 'text-destructive';
}
- return 'text-blue-700 dark:text-blue-300';
+ return 'text-info';
}
function truncateText(text: string, maxLength: number): string {
diff --git a/apps/mobile/src/components/agents/connectivity-banner.tsx b/apps/mobile/src/components/agents/connectivity-banner.tsx
index 295f63854d..2098b5081a 100644
--- a/apps/mobile/src/components/agents/connectivity-banner.tsx
+++ b/apps/mobile/src/components/agents/connectivity-banner.tsx
@@ -9,7 +9,7 @@ export function ConnectivityBanner() {
const colors = useThemeColors();
return (
-
+
No internet connection
diff --git a/apps/mobile/src/components/agents/markdown-link.test.ts b/apps/mobile/src/components/agents/markdown-link.test.ts
new file mode 100644
index 0000000000..2703c7a1e3
--- /dev/null
+++ b/apps/mobile/src/components/agents/markdown-link.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+
+import { resolveLinkAccessibilityLabel } from './markdown-link';
+
+describe('resolveLinkAccessibilityLabel', () => {
+ it('prefers an explicit title', () => {
+ expect(resolveLinkAccessibilityLabel('click here', 'https://example.com', 'Example site')).toBe(
+ 'Example site'
+ );
+ });
+
+ it('falls back to the visible link text when there is no title', () => {
+ expect(resolveLinkAccessibilityLabel('Kilo Code docs', 'https://kilo.ai/docs')).toBe(
+ 'Kilo Code docs'
+ );
+ });
+
+ it('falls back to the URL host when the link text is not a plain string', () => {
+ expect(resolveLinkAccessibilityLabel([], 'https://kilo.ai/docs/getting-started')).toBe(
+ 'kilo.ai'
+ );
+ });
+
+ it('falls back to the raw href when no host can be parsed', () => {
+ expect(resolveLinkAccessibilityLabel([], 'mailto:hello@kilo.ai')).toBe('mailto:hello@kilo.ai');
+ });
+});
diff --git a/apps/mobile/src/components/agents/markdown-link.ts b/apps/mobile/src/components/agents/markdown-link.ts
new file mode 100644
index 0000000000..e108d6cbb2
--- /dev/null
+++ b/apps/mobile/src/components/agents/markdown-link.ts
@@ -0,0 +1,22 @@
+import { type ReactNode } from 'react';
+
+const URL_HOST_PATTERN = /^[a-z][a-z\d+.-]*:\/\/([^/?#]+)/i;
+
+function getUrlHost(href: string): string | null {
+ return URL_HOST_PATTERN.exec(href)?.[1] ?? null;
+}
+
+/** Accessible label for a markdown link: explicit title, else visible link text, else the URL host. */
+export function resolveLinkAccessibilityLabel(
+ children: string | ReactNode[],
+ href: string,
+ title?: string
+): string {
+ if (title?.trim()) {
+ return title.trim();
+ }
+ if (typeof children === 'string' && children.trim()) {
+ return children.trim();
+ }
+ return getUrlHost(href) ?? href;
+}
diff --git a/apps/mobile/src/components/agents/markdown-palette.test.ts b/apps/mobile/src/components/agents/markdown-palette.test.ts
index 638aba290e..ae9d116bee 100644
--- a/apps/mobile/src/components/agents/markdown-palette.test.ts
+++ b/apps/mobile/src/components/agents/markdown-palette.test.ts
@@ -22,6 +22,7 @@ const colors = {
accentSoftForeground: '#1A1A10',
good: '#2F9A5F',
warn: '#B27214',
+ info: '#2563EB',
agentYuki: '#6B4FD6',
agentWorkclaw: '#4F5A10',
agentCloud: '#2F9A5F',
diff --git a/apps/mobile/src/components/agents/markdown-text.tsx b/apps/mobile/src/components/agents/markdown-text.tsx
index 2cde2cd9d5..643c05389e 100644
--- a/apps/mobile/src/components/agents/markdown-text.tsx
+++ b/apps/mobile/src/components/agents/markdown-text.tsx
@@ -1,9 +1,11 @@
import { type ReactNode, useMemo } from 'react';
-import { Linking, Text, type TextStyle, useColorScheme, View, type ViewStyle } from 'react-native';
+import { Text, type TextStyle, useColorScheme, View, type ViewStyle } from 'react-native';
import { Renderer, useMarkdown } from 'react-native-marked';
+import { openExternalUrl } from '@/lib/external-link';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { resolveLinkAccessibilityLabel } from './markdown-link';
import {
getMarkdownStyles,
getPalette,
@@ -91,10 +93,12 @@ class MarkdownRenderer extends Renderer {
selectable={this.selectable}
accessibilityRole="link"
accessibilityHint="Opens in a new window"
- accessibilityLabel={title ?? 'Link'}
+ accessibilityLabel={resolveLinkAccessibilityLabel(children, href, title)}
key={this.getKey()}
onPress={() => {
- void Linking.openURL(href);
+ void openExternalUrl(href, {
+ label: resolveLinkAccessibilityLabel(children, href, title),
+ });
}}
style={styles}
>
diff --git a/apps/mobile/src/components/agents/message-bubble.tsx b/apps/mobile/src/components/agents/message-bubble.tsx
index 8b5569a7d3..10fb60bba0 100644
--- a/apps/mobile/src/components/agents/message-bubble.tsx
+++ b/apps/mobile/src/components/agents/message-bubble.tsx
@@ -1,5 +1,5 @@
import { type StoredMessage } from 'cloud-agent-sdk';
-import { Pressable, View } from 'react-native';
+import { type AccessibilityActionEvent, Pressable, View } from 'react-native';
import { Bubble } from '@/components/ui/bubble';
@@ -32,6 +32,16 @@ export function MessageBubble({
void copyMessage(message);
};
+ // Long-press is an accelerator; expose the same "copy" action to
+ // accessibility tooling (VoiceOver/TalkBack rotor) since a long-press
+ // gesture isn't reliably discoverable there.
+ const copyAccessibilityActions = [{ name: 'copy', label: 'Copy message' }];
+ const handleAccessibilityAction = (event: AccessibilityActionEvent) => {
+ if (event.nativeEvent.actionName === 'copy') {
+ void copyMessage(message);
+ }
+ };
+
// Compaction-only message renders as a separator
const firstPart = message.parts[0];
if (message.parts.length === 1 && firstPart?.type === 'compaction') {
@@ -56,6 +66,8 @@ export function MessageBubble({
accessibilityRole="text"
accessibilityLabel="User message"
accessibilityHint="Long press to copy message text"
+ accessibilityActions={copyAccessibilityActions}
+ onAccessibilityAction={handleAccessibilityAction}
>
{textContent ? (
@@ -79,6 +91,8 @@ export function MessageBubble({
accessibilityRole="text"
accessibilityLabel="Assistant message"
accessibilityHint="Long press to copy message text"
+ accessibilityActions={copyAccessibilityActions}
+ onAccessibilityAction={handleAccessibilityAction}
>
{message.parts.map(part => (
diff --git a/apps/mobile/src/components/agents/message-error-boundary.tsx b/apps/mobile/src/components/agents/message-error-boundary.tsx
index f97022ba64..ecb3861420 100644
--- a/apps/mobile/src/components/agents/message-error-boundary.tsx
+++ b/apps/mobile/src/components/agents/message-error-boundary.tsx
@@ -30,8 +30,8 @@ export class MessageErrorBoundary extends Component {
override render(): ReactNode {
if (this.state.hasError) {
return (
-
- Failed to render content
+
+ Failed to render content
);
}
diff --git a/apps/mobile/src/components/agents/mobile-session-manager.ts b/apps/mobile/src/components/agents/mobile-session-manager.ts
index 8ccf91f4fb..3fd13a8ee2 100644
--- a/apps/mobile/src/components/agents/mobile-session-manager.ts
+++ b/apps/mobile/src/components/agents/mobile-session-manager.ts
@@ -70,29 +70,21 @@ export function createMobileAgentSessionManager({
lifecycleHooks: createNativeUserWebConnectionLifecycleHooks(),
userWebConnection,
resolveSession: async (kiloSessionId: KiloSessionId): Promise => {
- try {
- const session = await trpcClient.cliSessionsV2.get.query({ session_id: kiloSessionId });
- if (session.cloud_agent_session_id) {
- return {
- type: 'cloud-agent',
- kiloSessionId,
- cloudAgentSessionId: session.cloud_agent_session_id as CloudAgentSessionId,
- };
- }
- let isRemote = false;
- try {
- const active = await trpcClient.activeSessions.list.query();
- isRemote = active.sessions.some(s => s.id === kiloSessionId);
- } catch {
- // not remote
- }
- if (isRemote) {
- return { type: 'remote', kiloSessionId };
- }
- return { type: 'read-only', kiloSessionId };
- } catch {
- return { type: 'read-only', kiloSessionId };
+ // Read-only is only ever returned once we have successful evidence the
+ // session isn't cloud-agent or remote. A failed query here must
+ // propagate so it lands in the retryable error state instead of being
+ // silently misclassified as read-only.
+ const session = await trpcClient.cliSessionsV2.get.query({ session_id: kiloSessionId });
+ if (session.cloud_agent_session_id) {
+ return {
+ type: 'cloud-agent',
+ kiloSessionId,
+ cloudAgentSessionId: session.cloud_agent_session_id as CloudAgentSessionId,
+ };
}
+ const active = await trpcClient.activeSessions.list.query();
+ const isRemote = active.sessions.some(s => s.id === kiloSessionId);
+ return { type: isRemote ? 'remote' : 'read-only', kiloSessionId };
},
getTicket: async (sessionId: CloudAgentSessionId): Promise => {
const ticket = await withCloudAgentDiagnostics('getTicket', organizationId, async () => {
diff --git a/apps/mobile/src/components/agents/mode-selector.tsx b/apps/mobile/src/components/agents/mode-selector.tsx
index 0ee13e9ddf..a2e2bcc9a5 100644
--- a/apps/mobile/src/components/agents/mode-selector.tsx
+++ b/apps/mobile/src/components/agents/mode-selector.tsx
@@ -38,6 +38,9 @@ export function ModeSelector({ value, onChange, disabled = false }: Readonly new Set(favorites), [favorites]);
const [search, setSearch] = useState('');
const [bridge, setBridge] = useState(() => getModelPickerBridge());
@@ -82,9 +84,10 @@ export function ModelPickerContent() {
[bridge, search, favoriteIds]
);
+ // The favorite star button in ModelPickerOptionRow already fires its own
+ // selection haptic on press — this callback must not fire a second one.
const handleToggleFavorite = useCallback(
(option: SessionModelOption) => {
- void Haptics.selectionAsync();
toggleFavorite(modelPickerFavoriteId(option));
},
[toggleFavorite]
@@ -132,80 +135,75 @@ export function ModelPickerContent() {
);
if (!bridge) {
- return (
-
- No models available
-
- );
+ return ;
}
return (
- item.key}
- keyboardShouldPersistTaps="handled"
- keyboardDismissMode="on-drag"
- contentContainerStyle={{ paddingBottom: bottom }}
- ListHeaderComponent={
-
-
- Select Model
-
- Done
-
-
-
-
-
-
-
- }
- ListEmptyComponent={
-
-
- {search.trim() ? 'No models match your search' : 'No models available'}
-
-
- }
- renderItem={({ item }) => {
- if (item.type === 'header') {
- return (
-
-
- {item.title}
-
+
+ item.key}
+ keyboardShouldPersistTaps="handled"
+ keyboardDismissMode="on-drag"
+ contentContainerStyle={{ paddingBottom: bottom }}
+ ListHeaderComponent={
+
+
+
+
- );
+ {favoritesError ? (
+
+
+ {favoritesError}
+
+ ) : null}
+
}
-
- return (
-
- );
- }}
- />
+ }
+ renderItem={({ item }) => {
+ if (item.type === 'header') {
+ return (
+
+
+ {item.title}
+
+
+ );
+ }
+
+ return (
+
+ );
+ }}
+ />
+
);
}
diff --git a/apps/mobile/src/components/agents/model-selector.tsx b/apps/mobile/src/components/agents/model-selector.tsx
index e432da514c..1bbbf774ad 100644
--- a/apps/mobile/src/components/agents/model-selector.tsx
+++ b/apps/mobile/src/components/agents/model-selector.tsx
@@ -5,6 +5,7 @@ import { BookOpenCheck, Brain, Check, ChevronDown, Star } from 'lucide-react-nat
import { createContext, type ReactNode, useContext, useMemo } from 'react';
import { Pressable, ScrollView, View } from 'react-native';
+import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
import {
BYOK_MODEL_LABEL,
@@ -31,6 +32,7 @@ type ModelSelectorProps = {
options: (ModelOption | SessionModelOption)[];
onSelect: (modelId: string, variant: string, pickerSelection?: ModelPickerSelection) => void;
disabled?: boolean;
+ isLoading?: boolean;
};
type ModelPickerSelectionScopeContextValue = {
@@ -124,10 +126,16 @@ export function ModelSelector({
options,
onSelect,
disabled = false,
+ isLoading = false,
}: Readonly) {
const router = useRouter();
const colors = useThemeColors();
const selectionContext = useContext(ModelPickerSelectionScopeContext);
+
+ if (isLoading) {
+ return ;
+ }
+
const pickerOptions = options.map(option => toSessionModelOption(option));
const effectivelyDisabled = disabled || pickerOptions.every(option => option.unavailable);
const selectedModel = pickerOptions.find(option => option.id === value);
@@ -165,6 +173,7 @@ export function ModelSelector({
disabled={effectivelyDisabled}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
+ accessibilityState={{ disabled: effectivelyDisabled }}
className={cn(
'max-w-[240px] shrink flex-row items-center gap-1.5 rounded-full bg-secondary px-3 py-1.5 active:opacity-70',
effectivelyDisabled && 'opacity-50'
@@ -246,6 +255,7 @@ export function ModelPickerOptionRow({
disabled={option.unavailable}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
+ accessibilityState={{ disabled: option.unavailable, selected }}
>
{option.name}
diff --git a/apps/mobile/src/components/agents/permission-card.tsx b/apps/mobile/src/components/agents/permission-card.tsx
index 74e43d83d2..4bf0fecea9 100644
--- a/apps/mobile/src/components/agents/permission-card.tsx
+++ b/apps/mobile/src/components/agents/permission-card.tsx
@@ -40,7 +40,7 @@ export function PermissionCard({
return (
- Permission Required
+ Permission required
diff --git a/apps/mobile/src/components/agents/platform-filter-modal.tsx b/apps/mobile/src/components/agents/platform-filter-modal.tsx
index 007195c259..e5c2a9e76b 100644
--- a/apps/mobile/src/components/agents/platform-filter-modal.tsx
+++ b/apps/mobile/src/components/agents/platform-filter-modal.tsx
@@ -130,10 +130,11 @@ export function SessionFilterChips({
return (
{
onRemoveProject(gitUrl);
}}
+ accessibilityRole="button"
accessibilityLabel={`Remove ${label} project filter`}
>
(
{
onRemovePlatform(platform);
}}
+ accessibilityRole="button"
accessibilityLabel={`Remove ${platformFilterLabel(platform)} platform filter`}
>
@@ -189,15 +191,26 @@ export function SessionFilterModal({
return (
-
+
{
e.stopPropagation();
}}
>
- Filter Sessions
+ Filter sessions
diff --git a/apps/mobile/src/components/agents/question-card.tsx b/apps/mobile/src/components/agents/question-card.tsx
index 7a49c85181..5952594662 100644
--- a/apps/mobile/src/components/agents/question-card.tsx
+++ b/apps/mobile/src/components/agents/question-card.tsx
@@ -39,7 +39,9 @@ export function QuestionCard({
const [selectedOptions, setSelectedOptions] = useState>>({});
const [customSelected, setCustomSelected] = useState>({});
const customInputs = useRef>({});
- const [customHasText, setCustomHasText] = useState>({});
+ // Unread; setting it forces a re-render on every keystroke so
+ // `allQuestionsAnswered` (derived from the customInputs ref) stays in sync.
+ const [, setCustomHasText] = useState>({});
function toggleOption(questionIndex: number, optionIndex: number, multiple: boolean | undefined) {
setSelectedOptions(prev => {
@@ -110,34 +112,22 @@ export function QuestionCard({
function handleSubmit() {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
- const answers = buildAnswers();
-
- const unanswered = answers.findIndex(a => a.length === 0);
- if (unanswered !== -1) {
- Alert.alert('Please Answer All Questions', `Question ${unanswered + 1} needs an answer.`);
- return;
- }
-
- onAnswer(answers);
+ onAnswer(buildAnswers());
}
function handleReject() {
- Alert.alert('Skip Questions?', 'The agent will skip this step.', [
+ Alert.alert('Skip questions?', 'The agent will skip this step.', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Skip', style: 'destructive', onPress: onReject },
]);
}
- const hasOptionSelected = Object.values(selectedOptions).some(s => s.size > 0);
- const hasCustomAnswer = Object.entries(customSelected).some(
- ([idx, selected]) => selected && customHasText[Number(idx)]
- );
- const hasAnyAnswer = hasOptionSelected || hasCustomAnswer;
+ const allQuestionsAnswered = buildAnswers().every(answer => answer.length > 0);
return (
- Agent Needs Input
+ Agent needs input
@@ -187,6 +177,7 @@ export function QuestionCard({
toggleCustom(qIndex, question.multiple);
}}
disabled={isSubmitting}
+ accessibilityState={{ disabled: isSubmitting, selected: isCustomActive }}
className={cn(
'flex-row items-center rounded-md border px-3 py-2.5 shadow-sm shadow-black/5',
isCustomActive
@@ -221,12 +212,16 @@ export function QuestionCard({
Skip
-
+
{isSubmitting ? (
) : null}
- {isSubmitting ? 'Submitting…' : 'Submit'}
+ {isSubmitting ? 'Submitting…' : 'Send answers'}
diff --git a/apps/mobile/src/components/agents/reasoning-part-renderer.tsx b/apps/mobile/src/components/agents/reasoning-part-renderer.tsx
index f7d1a447a4..766f4787b8 100644
--- a/apps/mobile/src/components/agents/reasoning-part-renderer.tsx
+++ b/apps/mobile/src/components/agents/reasoning-part-renderer.tsx
@@ -24,10 +24,14 @@ export function ReasoningPartRenderer({
return (
{
setIsExpanded(prev => !prev);
}}
+ accessibilityRole="button"
+ accessibilityLabel={isStreaming ? 'Thinking' : 'Thought'}
+ accessibilityHint={isExpanded ? 'Collapse details' : 'Expand details'}
+ accessibilityState={{ expanded: isExpanded }}
>
{isStreaming ? 'Thinking' : 'Thought'}
{isExpanded ? (
diff --git a/apps/mobile/src/components/agents/reasoning-settings-modal.tsx b/apps/mobile/src/components/agents/reasoning-settings-modal.tsx
index 151e8c542c..0a49070c37 100644
--- a/apps/mobile/src/components/agents/reasoning-settings-modal.tsx
+++ b/apps/mobile/src/components/agents/reasoning-settings-modal.tsx
@@ -21,16 +21,25 @@ export function ReasoningSettingsModal({
{
event.stopPropagation();
}}
>
Reasoning
+ {/* The native Switch below is the single accessible control (it already
+ exposes an accessibility switch role/state on its own); this row is
+ a visual hit-target only, so a screen reader doesn't see two nested
+ switches. */}
{
if (!hasLoaded) {
@@ -39,10 +48,8 @@ export function ReasoningSettingsModal({
setDefaultExpanded(!defaultExpanded);
}}
disabled={!hasLoaded}
+ accessible={false}
className="flex-row items-center justify-between gap-3 rounded-lg p-2 active:opacity-70 disabled:opacity-50"
- accessibilityRole="switch"
- accessibilityState={{ checked: defaultExpanded, disabled: !hasLoaded }}
- accessibilityLabel="Expand reasoning by default"
hitSlop={
Platform.OS === 'android' ? { top: 12, bottom: 12, left: 12, right: 12 } : undefined
}
@@ -59,6 +66,7 @@ export function ReasoningSettingsModal({
value={defaultExpanded}
onValueChange={setDefaultExpanded}
disabled={!hasLoaded}
+ accessibilityLabel="Expand reasoning by default"
trackColor={{ false: colors.muted, true: colors.accentSoft }}
thumbColor={defaultExpanded ? colors.accentSoftForeground : '#FFFFFF'}
/>
diff --git a/apps/mobile/src/components/agents/repo-selector.tsx b/apps/mobile/src/components/agents/repo-selector.tsx
index f2426cc500..3f5e4dd8cf 100644
--- a/apps/mobile/src/components/agents/repo-selector.tsx
+++ b/apps/mobile/src/components/agents/repo-selector.tsx
@@ -49,6 +49,9 @@ export function RepoSelector({
) {
const manager = useSessionManager();
+ const router = useRouter();
const messages = useAtomValue(manager.atoms.messagesList);
const isLoading = useAtomValue(manager.atoms.isLoading);
@@ -242,6 +249,10 @@ export function SessionDetailContent({
}
}, [manager]);
+ const handleBackToSessions = useCallback(() => {
+ router.replace('/(app)/(tabs)/(2_agents)' as Href);
+ }, [router]);
+
const handleModelSelect = useCallback(
(value: string, variant: string, pickerSelection?: ModelPickerSelection) => {
if (activeSessionType === 'remote') {
@@ -293,7 +304,7 @@ export function SessionDetailContent({
statusIndicator !== null || (cloudStatus !== null && cloudStatus.type !== 'ready'),
});
- const emptyStateText = error ?? (statusIndicator ? null : 'No messages yet');
+ const emptyStateText = statusIndicator ? null : 'No messages yet';
const title =
fetchedData?.kiloSessionId === sessionId ? (fetchedData.title ?? 'Session') : 'Session';
@@ -316,21 +327,25 @@ export function SessionDetailContent({
toast.error('Select a model before sending');
return;
}
- try {
- await manager.send({
- payload: {
- type: 'prompt',
- prompt: text,
- mode: currentMode,
- model: currentModel,
- variant: currentVariant || undefined,
- },
- ...(supportsAttachments && attachments ? { attachments } : {}),
- });
- captureEvent(MESSAGE_SENT_EVENT, { surface: analyticsSurface });
- } catch {
- toast.error('Failed to send message. Please try again.');
+ // manager.send() reports failures via its own return value (and toasts
+ // through the manager's onSendFailed hook) rather than rejecting — it
+ // is the single toast owner for send failures. Throw here, without a
+ // second toast, purely so the composer's `await onSend(...)` sees the
+ // rejection and preserves the draft.
+ const sent = await manager.send({
+ payload: {
+ type: 'prompt',
+ prompt: text,
+ mode: currentMode,
+ model: currentModel,
+ variant: currentVariant || undefined,
+ },
+ ...(supportsAttachments && attachments ? { attachments } : {}),
+ });
+ if (!sent) {
+ throw new Error('Failed to send message');
}
+ captureEvent(MESSAGE_SENT_EVENT, { surface: analyticsSurface });
},
[
manager,
@@ -437,10 +452,24 @@ export function SessionDetailContent({
function renderContent() {
if (shouldBlockMessages) {
+ return ;
+ }
+ if (error && messages.length === 0) {
return (
-
-
- Loading session…
+
+ {
+ void manager.switchSession(sessionId);
+ }}
+ />
+
+ Back to sessions
+
);
}
@@ -449,11 +478,11 @@ export function SessionDetailContent({
{statusIndicator ? : null}
{emptyStateText ? (
-
- {emptyStateText}
-
+
) : null}
);
@@ -485,3 +514,22 @@ export function SessionDetailContent({
);
}
}
+
+// Mirrors MessageBubble's bubble geometry (px-4 py-1/py-2 wrapper,
+// rounded-2xl with an asymmetric "tail" corner, self-start/self-end
+// alignment) so the loading state reads as a message list, not a spinner.
+export function SessionSkeletonMessages() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/agents/session-list-content.tsx b/apps/mobile/src/components/agents/session-list-content.tsx
index 42e2880536..2f07290ec8 100644
--- a/apps/mobile/src/components/agents/session-list-content.tsx
+++ b/apps/mobile/src/components/agents/session-list-content.tsx
@@ -1,5 +1,5 @@
-import { Bot, Plus, Search } from 'lucide-react-native';
-import { useCallback, useMemo } from 'react';
+import { Bot, Plus, Search, SearchX } from 'lucide-react-native';
+import { useCallback, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Platform,
@@ -32,12 +32,17 @@ type AgentSessionListContentProps = {
storedSessions: StoredSession[];
hasAnySessions: boolean;
isLoading: boolean;
+ isSearchPending: boolean;
isError: boolean;
isFetchingNextPage: boolean;
refetch: () => Promise;
+ onRetry: () => void;
onEndReached: () => void;
onSessionPress: (sessionId: string, organizationId?: string | null) => void;
onSearchChange: (text: string) => void;
+ hasActiveQuery: boolean;
+ isSearching: boolean;
+ onClearQuery: () => void;
onCreateSession: () => void;
};
@@ -46,17 +51,32 @@ export function AgentSessionListContent({
storedSessions,
hasAnySessions,
isLoading,
+ isSearchPending,
isError,
isFetchingNextPage,
refetch,
+ onRetry,
onEndReached,
onSessionPress,
onSearchChange,
+ hasActiveQuery,
+ isSearching,
+ onClearQuery,
onCreateSession,
}: Readonly) {
const colors = useThemeColors();
const { bottom } = useSafeAreaInsets();
const { deleteSession, renameSession } = useSessionMutations();
+ const [refreshing, setRefreshing] = useState(false);
+ const searchInputRef = useRef(null);
+
+ // The search TextInput is uncontrolled (see iOS TextInput rules — controlled
+ // `value` causes keystroke races), so clearing the query in state alone
+ // wouldn't clear what's visibly typed. Imperatively clear the input too.
+ const handleClearQuery = useCallback(() => {
+ searchInputRef.current?.clear();
+ onClearQuery();
+ }, [onClearQuery]);
// The tab bar is an absolutely-positioned overlay, so scrollable content
// must clear it or the last rows are stuck underneath it.
const tabBarClearanceStyle = useMemo(
@@ -64,22 +84,42 @@ export function AgentSessionListContent({
[bottom]
);
+ // When the list is empty the error surface below (QueryError + retry)
+ // already covers it — don't double up with the inline header line.
+ const showInlineError = isError && sections.length > 0;
+
const listHeader = useMemo(
() => (
-
-
-
+
+
+
+
+
+ {isSearchPending ? (
+
+
+
+ Searching…
+
+
+ ) : null}
+ {showInlineError ? (
+
+ Could not refresh — showing the last saved list.
+
+ ) : null}
),
- [colors.mutedForeground, onSearchChange]
+ [colors.mutedForeground, showInlineError, isSearchPending, onSearchChange]
);
const emptyStateAction = useMemo(
@@ -92,18 +132,51 @@ export function AgentSessionListContent({
[colors.foreground, onCreateSession]
);
- const listEmptyComponent = useMemo(
+ const clearQueryAction = useMemo(
+ () => (
+
+ {isSearching ? 'Clear search' : 'Clear filters'}
+
+ ),
+ [handleClearQuery, isSearching]
+ );
+
+ // Only reachable when `hasAnySessions` is true (the true first-use empty
+ // state is handled separately below), which means an active search or
+ // filter narrowed the results to zero matches — never show the "create a
+ // task" CTA here, it's not the fix for a filter that's too narrow.
+ const filteredEmptyComponent = useMemo(
() => (
),
- [emptyStateAction]
+ [clearQueryAction, isSearching]
+ );
+
+ // The query in error produced no rows to show — surface a retry for it
+ // (search or list, whichever `onRetry` targets) instead of pretending the
+ // empty result is a real "no matches".
+ const queryErrorEmptyComponent = useMemo(
+ () => (
+
+
+ {clearQueryAction}
+
+ ),
+ [clearQueryAction, isSearching, onRetry]
);
const organizationIdBySessionId = useMemo(
@@ -112,7 +185,14 @@ export function AgentSessionListContent({
);
const handleRefresh = useCallback(() => {
- void refetch();
+ void (async () => {
+ setRefreshing(true);
+ try {
+ await refetch();
+ } finally {
+ setRefreshing(false);
+ }
+ })();
}, [refetch]);
const renderItem = useCallback(
@@ -168,17 +248,24 @@ export function AgentSessionListContent({
{Array.from({ length: 8 }, (_, i) => (
-
+
))}
);
}
- if (isError) {
+ // Full-screen error only when there is nothing cached to fall back on —
+ // a background refetch/search failure with stale sessions already in
+ // cache (keepPreviousData) must never blank out what's already rendered.
+ if (isError && !hasAnySessions) {
return (
-
- void refetch()} />
+
+
);
}
@@ -203,6 +290,11 @@ export function AgentSessionListContent({
);
}
+ let emptyComponent = null;
+ if (hasActiveQuery) {
+ emptyComponent = isError ? queryErrorEmptyComponent : filteredEmptyComponent;
+ }
+
return (
@@ -211,7 +303,7 @@ export function AgentSessionListContent({
renderSectionHeader={renderSectionHeader}
keyExtractor={keyExtractor}
ListHeaderComponent={listHeader}
- ListEmptyComponent={listEmptyComponent}
+ ListEmptyComponent={emptyComponent}
ListFooterComponent={
isFetchingNextPage ? (
@@ -224,7 +316,7 @@ export function AgentSessionListContent({
keyboardDismissMode="on-drag"
onEndReached={onEndReached}
onEndReachedThreshold={0.5}
- refreshControl={}
+ refreshControl={}
/>
);
diff --git a/apps/mobile/src/components/agents/session-list-header-actions.tsx b/apps/mobile/src/components/agents/session-list-header-actions.tsx
new file mode 100644
index 0000000000..0bf251002e
--- /dev/null
+++ b/apps/mobile/src/components/agents/session-list-header-actions.tsx
@@ -0,0 +1,53 @@
+import { Plus, SlidersHorizontal } from 'lucide-react-native';
+import { Pressable, View } from 'react-native';
+
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+
+type SessionListHeaderActionsProps = {
+ hasActiveFilter: boolean;
+ /** Hides the header "New session" button — the empty-state CTA is the only
+ * creation affordance while there are no sessions yet. */
+ showNewSession: boolean;
+ onNewSession: () => void;
+ onOpenFilters: () => void;
+};
+
+export function SessionListHeaderActions({
+ hasActiveFilter,
+ showNewSession,
+ onNewSession,
+ onOpenFilters,
+}: Readonly) {
+ const colors = useThemeColors();
+
+ return (
+
+ {showNewSession ? (
+
+
+
+ ) : null}
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/agents/session-list-screen.tsx b/apps/mobile/src/components/agents/session-list-screen.tsx
index 1ef998dbd1..35c1c458dd 100644
--- a/apps/mobile/src/components/agents/session-list-screen.tsx
+++ b/apps/mobile/src/components/agents/session-list-screen.tsx
@@ -1,10 +1,10 @@
-import { Plus, SlidersHorizontal } from 'lucide-react-native';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { Pressable, View } from 'react-native';
+import { 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 { SessionListHeaderActions } from '@/components/agents/session-list-header-actions';
import {
type ProjectFilterOption,
SessionFilterChips,
@@ -25,14 +25,12 @@ import {
useRecentAgentRepositories,
} from '@/lib/hooks/use-agent-sessions';
import { usePersistedAgentSessionFilters } from '@/lib/hooks/use-persisted-agent-session-filters';
-import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { useOrganization } from '@/lib/organization-context';
import { type Href, useFocusEffect, useRouter } from 'expo-router';
export function AgentSessionListScreen() {
const router = useRouter();
- const colors = useThemeColors();
const { organizationId, isLoaded: orgLoaded } = useOrganization();
const {
@@ -57,6 +55,13 @@ export function AgentSessionListScreen() {
}, 300);
}, []);
+ const handleClearSearch = useCallback(() => {
+ if (searchTimerRef.current) {
+ clearTimeout(searchTimerRef.current);
+ }
+ setSearchQuery('');
+ }, []);
+
useEffect(
() => () => {
if (searchTimerRef.current) {
@@ -106,6 +111,31 @@ export function AgentSessionListScreen() {
enabled: ready,
});
+ // While searching, only the search query's own error/pending state matters —
+ // it's the one actually driving what's on screen. Retrying should hit
+ // whichever query is really in error instead of always refetching the base
+ // list underneath a failed search.
+ const contentIsError = isSearching ? search.isError : isError;
+ const isSearchPending = isSearching && search.isPending;
+ const searchRefetch = search.refetch;
+ const handleRetry = useCallback(() => {
+ if (isSearching) {
+ void searchRefetch();
+ } else {
+ void refetch();
+ }
+ }, [isSearching, searchRefetch, refetch]);
+
+ // Pull-to-refresh must also retry the search query while one is active —
+ // it's the query actually driving what's on screen.
+ const handleRefetch = useCallback(async () => {
+ if (!isSearching) {
+ await refetch();
+ return;
+ }
+ await Promise.all([searchRefetch(), refetch()]);
+ }, [isSearching, refetch, searchRefetch]);
+
const refetchRef = useRef(refetch);
useEffect(() => {
refetchRef.current = refetch;
@@ -140,6 +170,12 @@ export function AgentSessionListScreen() {
return [...byGitUrl.values()];
}, [projectFilter, recentRepositories?.repositories]);
+ // While the first fetch for this search text is still in flight (no
+ // keepPreviousData to fall back on yet), render as if no search were
+ // applied instead of blanking to an empty/mismatched list —
+ // `isSearchPending` drives a lightweight inline indicator instead.
+ const effectiveSearchQuery = isSearchPending ? '' : searchQuery;
+
const sections = useMemo(() => {
const result: SessionSection[] = [];
const storedSessionIds = new Set(storedSessions.map(session => session.session_id));
@@ -157,7 +193,9 @@ export function AgentSessionListScreen() {
return false;
}
- return searchQuery ? matchesSearch(searchQuery, session.title, session.gitUrl ?? null) : true;
+ return effectiveSearchQuery
+ ? matchesSearch(effectiveSearchQuery, session.title, session.gitUrl ?? null)
+ : true;
});
if (filteredActive.length > 0) {
@@ -175,7 +213,7 @@ export function AgentSessionListScreen() {
// Stored sessions are cursor-paginated, so a client-side filter would only
// see the loaded pages. When a query is active, use the server search
// results (which cover the full history) instead.
- const storedGroups = searchQuery ? search.dateGroups : dateGroups;
+ const storedGroups = effectiveSearchQuery ? search.dateGroups : dateGroups;
for (const group of storedGroups) {
if (group.sessions.length > 0) {
result.push({
@@ -196,9 +234,9 @@ export function AgentSessionListScreen() {
activeSessionIds,
activeSessions,
dateGroups,
+ effectiveSearchQuery,
projectFilter,
search.dateGroups,
- searchQuery,
storedSessions,
]);
@@ -219,6 +257,12 @@ export function AgentSessionListScreen() {
}, [fetchNextPage, hasNextPage, isFetchingNextPage, isSearching]);
const hasActiveFilter = platformFilter.length > 0 || projectFilter.length > 0;
+ const hasAnySessions = storedSessions.length > 0 || activeSessions.length > 0;
+
+ const handleClearQuery = useCallback(() => {
+ handleClearSearch();
+ setFilters({ platformFilter: [], projectFilter: [] });
+ }, [handleClearSearch, setFilters]);
return (
@@ -228,34 +272,16 @@ export function AgentSessionListScreen() {
showBackButton={false}
className="px-[22px]"
headerRight={
-
- {
- router.push(getNewAgentSessionPath(organizationId) as Href);
- }}
- // right slop capped so the expanded targets don't overlap inside the 16px gap
- hitSlop={{ top: 11, bottom: 11, left: 11, right: 8 }}
- accessibilityRole="button"
- accessibilityLabel="New session"
- className="active:opacity-70"
- >
-
-
- {
- setShowFilterModal(true);
- }}
- hitSlop={{ top: 12, bottom: 12, left: 8, right: 12 }}
- accessibilityRole="button"
- accessibilityLabel="Filter sessions"
- className="active:opacity-70"
- >
-
-
-
+ {
+ router.push(getNewAgentSessionPath(organizationId) as Href);
+ }}
+ onOpenFilters={() => {
+ setShowFilterModal(true);
+ }}
+ />
}
/>
@@ -275,14 +301,19 @@ export function AgentSessionListScreen() {
0 || activeSessions.length > 0}
- isLoading={isLoading || !ready || (isSearching && search.isPending)}
- isError={isError || (isSearching && search.isError)}
+ hasAnySessions={hasAnySessions}
+ isLoading={isLoading || !ready}
+ isSearchPending={isSearchPending}
+ isError={contentIsError}
isFetchingNextPage={isFetchingNextPage}
- refetch={refetch}
+ refetch={handleRefetch}
+ onRetry={handleRetry}
onEndReached={handleEndReached}
onSessionPress={navigateToSession}
onSearchChange={handleSearchChange}
+ hasActiveQuery={isSearching || hasActiveFilter}
+ isSearching={isSearching}
+ onClearQuery={handleClearQuery}
onCreateSession={() => {
router.push(getNewAgentSessionPath(organizationId) as Href);
}}
diff --git a/apps/mobile/src/components/agents/session-row.tsx b/apps/mobile/src/components/agents/session-row.tsx
index 851d7208a8..8851312efe 100644
--- a/apps/mobile/src/components/agents/session-row.tsx
+++ b/apps/mobile/src/components/agents/session-row.tsx
@@ -75,7 +75,7 @@ function showDeleteConfirm(onDelete: () => void) {
/** iOS-only — uses Alert.prompt which is unavailable on Android. */
function showRenamePrompt(currentTitle: string, onRename: (newTitle: string) => void) {
Alert.prompt(
- 'Rename Session',
+ 'Rename session',
'Enter a new name for this session',
[
{ text: 'Cancel', style: 'cancel' },
@@ -182,7 +182,7 @@ export function StoredSessionRow({
>
- Rename Session
+ Rename session
{
@@ -201,10 +201,19 @@ export function StoredSessionRow({
setRenameVisible(false);
}}
hitSlop={8}
+ accessibilityRole="button"
+ accessibilityLabel="Cancel"
+ className="active:opacity-70"
>
Cancel
-
+
Rename
diff --git a/apps/mobile/src/components/agents/session-status-indicator.tsx b/apps/mobile/src/components/agents/session-status-indicator.tsx
index ae66feafb6..1ca5e3b1ec 100644
--- a/apps/mobile/src/components/agents/session-status-indicator.tsx
+++ b/apps/mobile/src/components/agents/session-status-indicator.tsx
@@ -5,8 +5,6 @@ import { type SessionStatusIndicator as SessionStatusIndicatorType } from 'cloud
import { Text } from '@/components/ui/text';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
-const warningColor = 'hsl(38, 92%, 50%)';
-
type SessionStatusIndicatorProps = {
indicator: SessionStatusIndicatorType;
};
@@ -34,8 +32,8 @@ function IndicatorContent({ indicator }: Readonly)
case 'warning': {
return (
-
- {indicator.message}
+
+ {indicator.message}
);
}
diff --git a/apps/mobile/src/components/agents/tool-card-shell.tsx b/apps/mobile/src/components/agents/tool-card-shell.tsx
index ffc441d01b..774a66c5d7 100644
--- a/apps/mobile/src/components/agents/tool-card-shell.tsx
+++ b/apps/mobile/src/components/agents/tool-card-shell.tsx
@@ -51,6 +51,11 @@ export function ToolCardShell({
}
}
+ let accessibilityHint: string | undefined = undefined;
+ if (hasContent) {
+ accessibilityHint = isExpanded ? 'Collapse details' : 'Expand details';
+ }
+
return (
{status === 'pending' || status === 'running' ? (
diff --git a/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx
index 4dba245fa4..c4b3449299 100644
--- a/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx
+++ b/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx
@@ -40,7 +40,7 @@ export function BashToolCard({ part }: Readonly<{ part: ToolPart }>) {
) : null}
{error ? (
-
+
{error}
) : null}
diff --git a/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx
index daa42ab16d..2a0b34de60 100644
--- a/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx
+++ b/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx
@@ -49,7 +49,7 @@ export function EditToolCard({ part }: Readonly<{ part: ToolPart }>) {
) : null}
{error ? (
-
+
{error}
) : null}
diff --git a/apps/mobile/src/components/agents/tool-cards/generic-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/generic-tool-card.tsx
index eb5b5c29d4..3d3cea294a 100644
--- a/apps/mobile/src/components/agents/tool-cards/generic-tool-card.tsx
+++ b/apps/mobile/src/components/agents/tool-cards/generic-tool-card.tsx
@@ -46,7 +46,7 @@ export function GenericToolCard({ part }: Readonly<{ part: ToolPart }>) {
) : null}
{error ? (
-
+
{error}
) : null}
diff --git a/apps/mobile/src/components/agents/tool-cards/glob-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/glob-tool-card.tsx
index 8acda4ba8e..5dcba263e8 100644
--- a/apps/mobile/src/components/agents/tool-cards/glob-tool-card.tsx
+++ b/apps/mobile/src/components/agents/tool-cards/glob-tool-card.tsx
@@ -41,7 +41,7 @@ export function GlobToolCard({ part }: Readonly<{ part: ToolPart }>) {
) : null}
{error ? (
-
+
{error}
) : null}
diff --git a/apps/mobile/src/components/agents/tool-cards/grep-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/grep-tool-card.tsx
index 00cf21c9bb..db19da1b23 100644
--- a/apps/mobile/src/components/agents/tool-cards/grep-tool-card.tsx
+++ b/apps/mobile/src/components/agents/tool-cards/grep-tool-card.tsx
@@ -45,7 +45,7 @@ export function GrepToolCard({ part }: Readonly<{ part: ToolPart }>) {
) : null}
{error ? (
-
+
{error}
) : null}
diff --git a/apps/mobile/src/components/agents/tool-cards/list-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/list-tool-card.tsx
index 41cdb9fd4e..1635883d80 100644
--- a/apps/mobile/src/components/agents/tool-cards/list-tool-card.tsx
+++ b/apps/mobile/src/components/agents/tool-cards/list-tool-card.tsx
@@ -28,7 +28,7 @@ export function ListToolCard({ part }: Readonly<{ part: ToolPart }>) {
) : null}
{error ? (
-
+
{error}
) : null}
diff --git a/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx
index 4fdd53f8b2..19684f2558 100644
--- a/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx
+++ b/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx
@@ -48,7 +48,7 @@ export function ReadToolCard({ part }: Readonly<{ part: ToolPart }>) {
) : null}
{error ? (
-
+
{error}
) : null}
diff --git a/apps/mobile/src/components/agents/tool-cards/task-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/task-tool-card.tsx
index 9e7f763e7a..f7d91ff949 100644
--- a/apps/mobile/src/components/agents/tool-cards/task-tool-card.tsx
+++ b/apps/mobile/src/components/agents/tool-cards/task-tool-card.tsx
@@ -27,7 +27,7 @@ export function TaskToolCard({ part }: Readonly<{ part: ToolPart }>) {
) : null}
{error ? (
-
+
{error}
) : null}
diff --git a/apps/mobile/src/components/agents/tool-cards/todo-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/todo-tool-card.tsx
index 30d6807123..1ad01ed034 100644
--- a/apps/mobile/src/components/agents/tool-cards/todo-tool-card.tsx
+++ b/apps/mobile/src/components/agents/tool-cards/todo-tool-card.tsx
@@ -23,7 +23,7 @@ export function TodoToolCard({ part }: Readonly<{ part: ToolPart }>) {
) : null}
{error ? (
-
+
{error}
) : null}
diff --git a/apps/mobile/src/components/agents/tool-cards/web-search-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/web-search-tool-card.tsx
index cb7a7297cd..13f835fb89 100644
--- a/apps/mobile/src/components/agents/tool-cards/web-search-tool-card.tsx
+++ b/apps/mobile/src/components/agents/tool-cards/web-search-tool-card.tsx
@@ -32,7 +32,7 @@ export function WebSearchToolCard({ part }: Readonly<{ part: ToolPart }>) {
) : null}
{error ? (
-
+
{error}
) : null}
diff --git a/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx
index 94f955d4ab..771830e831 100644
--- a/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx
+++ b/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx
@@ -25,7 +25,7 @@ export function WriteToolCard({ part }: Readonly<{ part: ToolPart }>) {
) : null}
{error ? (
-
+
{error}
) : null}
diff --git a/apps/mobile/src/components/app-root-providers.tsx b/apps/mobile/src/components/app-root-providers.tsx
index 0e76b5711b..61ac707afd 100644
--- a/apps/mobile/src/components/app-root-providers.tsx
+++ b/apps/mobile/src/components/app-root-providers.tsx
@@ -1,17 +1,21 @@
import { ActionSheetProvider } from '@expo/react-native-action-sheet';
import { PortalHost } from '@rn-primitives/portal';
import { QueryClientProvider } from '@tanstack/react-query';
+import { CheckCircle2, Info, Loader, TriangleAlert, XCircle } from 'lucide-react-native';
import { type ReactNode } from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { Toaster } from 'sonner-native';
import { AuthProvider } from '@/lib/auth/auth-context';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { OrganizationProvider } from '@/lib/organization-context';
import { queryClient } from '@/lib/query-client';
import { QueryClientNativeLifecycle } from '@/lib/query-client-lifecycle';
import { trpcClient, TRPCProvider } from '@/lib/trpc';
export function AppRootProviders({ children }: { readonly children: ReactNode }) {
+ const colors = useThemeColors();
+
return (
@@ -22,8 +26,33 @@ export function AppRootProviders({ children }: { readonly children: ReactNode })
<>
{children}
-
+ {/*
+ Toaster mounts last so it renders above PortalHost overlays (sheets/dropdowns
+ built on @rn-primitives/portal) — last sibling wins for overlapping overlays.
+ Ground truth (D2): prior on-device testing (2026-07-07, iOS) found sonner-native
+ toasts render BEHIND Expo formSheets despite FullWindowOverlay; this reordering
+ addresses Portal overlays only — sheets/modals still need inline errors (P2);
+ re-verification scheduled in the final device pass.
+ */}
+ ,
+ error: ,
+ warning: ,
+ info: ,
+ loading: ,
+ }}
+ toastOptions={{
+ style: {
+ backgroundColor: colors.card,
+ borderColor: colors.border,
+ borderWidth: 1,
+ },
+ titleStyle: { color: colors.foreground },
+ descriptionStyle: { color: colors.mutedForeground },
+ }}
+ />
>
diff --git a/apps/mobile/src/components/code-reviewer/bitbucket-connect-form.tsx b/apps/mobile/src/components/code-reviewer/bitbucket-connect-form.tsx
index 4fc269b455..f6bbe7a57a 100644
--- a/apps/mobile/src/components/code-reviewer/bitbucket-connect-form.tsx
+++ b/apps/mobile/src/components/code-reviewer/bitbucket-connect-form.tsx
@@ -1,4 +1,4 @@
-import { useRef } from 'react';
+import { useRef, useState } from 'react';
import { ActivityIndicator, TextInput, View } from 'react-native';
import { toast } from 'sonner-native';
@@ -10,6 +10,7 @@ import { useThemeColors } from '@/lib/hooks/use-theme-colors';
export function BitbucketConnectForm({ scope }: Readonly<{ scope: string }>) {
const colors = useThemeColors();
const tokenRef = useRef('');
+ const [canConnect, setCanConnect] = useState(false);
const connect = useConnectBitbucket(scope);
const onConnect = () => {
@@ -45,9 +46,14 @@ export function BitbucketConnectForm({ scope }: Readonly<{ scope: string }>) {
secureTextEntry
onChangeText={value => {
tokenRef.current = value;
+ setCanConnect(value.trim().length > 0);
}}
/>
-
+
{connect.isPending ? : null}
Connect
diff --git a/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx b/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx
index 1138bdfec4..902b532183 100644
--- a/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx
+++ b/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx
@@ -1,131 +1,152 @@
import * as Haptics from 'expo-haptics';
import { type Href, useRouter } from 'expo-router';
-import {
- FileSliders,
- FolderGit2,
- MessageSquareText,
- ScrollText,
- ShieldCheck,
-} from 'lucide-react-native';
-import { ScrollView, Switch, View } from 'react-native';
+import { Switch, View } from 'react-native';
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
import { openModelPicker } from '@/components/agents/model-selector';
import { BitbucketConnectForm } from '@/components/code-reviewer/bitbucket-connect-form';
+import {
+ buildOverviewRows,
+ resolveRowOnPress,
+} from '@/components/code-reviewer/platform-overview-rows';
+import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
+import { Button } from '@/components/ui/button';
import { ConfigureRow } from '@/components/ui/configure-row';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
+import { TabScreenScrollView, useTabBarBottomPadding } from '@/components/tab-screen';
import { PLATFORM_CAPABILITIES } from '@/lib/code-reviewer-config';
+import { WEB_BASE_URL } from '@/lib/config';
+import { openExternalUrl } from '@/lib/external-link';
import { useAvailableModels } from '@/lib/hooks/use-available-models';
import {
+ classifyProviderState,
useBitbucketReadiness,
type useReviewConfig,
useSaveReviewConfig,
type useToggleReviewer,
} from '@/lib/hooks/use-code-reviewer';
+import { getBitbucketIntegrationUrl } from '@/lib/integration-urls';
const capabilities = PLATFORM_CAPABILITIES.bitbucket;
-const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1);
export function BitbucketOverview({
scope,
config,
toggle,
canEdit,
+ permissionLoading,
}: Readonly<{
scope: string;
config: ReturnType;
toggle: ReturnType;
canEdit: boolean;
+ permissionLoading: boolean;
}>) {
const router = useRouter();
+ const paddingBottom = useTabBarBottomPadding();
const readiness = useBitbucketReadiness(scope);
const save = useSaveReviewConfig(scope, 'bitbucket');
const { models, isLoading: modelsLoading } = useAvailableModels(scope);
- const isLoading = readiness.isLoading || config.isLoading;
- const connected = readiness.data?.connected === true;
+ const providerState = classifyProviderState({
+ isLoading: readiness.isLoading,
+ isError: readiness.isError,
+ isFetching: readiness.isFetching,
+ connected: readiness.data?.connected,
+ hasData: readiness.data !== undefined,
+ refetch: () => void readiness.refetch(),
+ });
+
+ if (providerState.status === 'error') {
+ return (
+
+
+
+ {
+ providerState.refetch();
+ }}
+ isRetrying={providerState.isRetrying}
+ />
+
+
+ );
+ }
+
+ const isLoading = providerState.status === 'loading' || config.isLoading || permissionLoading;
+ const connected = providerState.status === 'connected';
+
+ // A connected workspace whose config fails to load (and has no stale
+ // cache to fall back on) has nothing to show — surface a retry instead of
+ // a header over blank space. A background refetch failure with data
+ // already cached falls through to the normal content below, unaffected.
+ if (!isLoading && connected && config.isError && config.data == null) {
+ return (
+
+
+
+ {
+ void config.refetch();
+ }}
+ isRetrying={config.isFetching}
+ />
+
+
+ );
+ }
const pushField = (field: string) => {
router.push(`/(app)/(tabs)/(3_profile)/code-reviewer/${scope}/bitbucket/${field}` as Href);
};
const data = config.data;
+ // Same gating as GitHub/GitLab (T5.5): a workspace that isn't fully ready,
+ // or has no repositories in scope, must not be flipped on blind. Only
+ // blocks turning the toggle ON.
+ const hasRepoSelection =
+ data != null &&
+ (data.repositorySelectionMode === 'all' || data.selectedRepositoryIds.length > 0);
+ const isReady = readiness.data?.ready !== false;
+ const canEnableAutoReview = hasRepoSelection && isReady;
const rows =
data == null
? null
- : [
- {
- field: 'style',
- icon: MessageSquareText,
- title: 'Review Style',
- subtitle: capitalize(data.reviewStyle),
+ : buildOverviewRows({
+ data,
+ capabilities,
+ models,
+ modelsLoading,
+ onOpenModelPicker: () => {
+ openModelPicker(router, {
+ options: models,
+ value: data.modelSlug,
+ variant: data.thinkingEffort ?? '',
+ onSelect: (modelSlug, variant) => {
+ save.mutate({ modelSlug, thinkingEffort: variant || null });
+ },
+ });
},
- {
- field: 'focus-areas',
- icon: ShieldCheck,
- title: 'Focus Areas',
- subtitle:
- data.focusAreas.length > 0 ? data.focusAreas.map(capitalize).join(', ') : 'All areas',
- },
- {
- field: 'instructions',
- icon: ScrollText,
- title: 'Custom Instructions',
- subtitle: data.customInstructions ? 'Set' : 'None',
- },
- {
- field: 'model',
- icon: FileSliders,
- title: 'Model',
- subtitle: models.find(model => model.id === data.modelSlug)?.name ?? data.modelSlug,
- onPress:
- modelsLoading || models.length === 0
- ? undefined
- : () => {
- openModelPicker(router, {
- options: models,
- value: data.modelSlug,
- variant: data.thinkingEffort ?? '',
- onSelect: (modelSlug, variant) => {
- save.mutate({ modelSlug, thinkingEffort: variant || null });
- },
- });
- },
- },
- {
- field: 'repos',
- icon: FolderGit2,
- title: 'Repositories',
- subtitle: `${data.selectedRepositoryIds.length} selected`,
- },
- ];
-
- const resolveRowOnPress = (row: NonNullable[number]) => {
- if (!canEdit) {
- return undefined;
- }
- if ('onPress' in row) {
- return row.onPress;
- }
- return () => {
- pushField(row.field);
- };
- };
+ });
return (
-
{isLoading && (
-
-
+
+
+ {Array.from({ length: 6 }, (_, index) => (
+
+ ))}
+
)}
@@ -145,26 +166,62 @@ export function BitbucketOverview({
{!isLoading && connected && config.data != null && rows != null && (
{readiness.data?.ready === false && (
-
- Setup incomplete — finish configuration on kilo.ai
-
+
+
+ Setup incomplete — finish configuration on kilo.ai
+
+ {
+ void openExternalUrl(getBitbucketIntegrationUrl(WEB_BASE_URL, scope), {
+ label: 'Bitbucket setup',
+ });
+ }}
+ >
+ Finish setup
+
+
)}
-
-
- Automatic reviews
-
- {readiness.data?.workspace?.slug ?? ''}
-
+
+
+
+ Automatic reviews
+
+ {readiness.data?.workspace?.slug ?? ''}
+
+
+ {
+ void Haptics.selectionAsync();
+ toggle.mutate({ isEnabled: value });
+ }}
+ />
- {
- void Haptics.selectionAsync();
- toggle.mutate({ isEnabled: value });
- }}
- />
+ {isReady && !hasRepoSelection && (
+
+
+ Select at least one repository to enable automatic reviews.
+
+ {
+ pushField('repos');
+ }}
+ >
+ Select repositories
+
+
+ )}
@@ -175,7 +232,7 @@ export function BitbucketOverview({
title={row.title}
subtitle={row.subtitle}
last={index === rows.length - 1}
- onPress={resolveRowOnPress(row)}
+ onPress={resolveRowOnPress(row, canEdit, pushField)}
/>
))}
@@ -188,7 +245,7 @@ export function BitbucketOverview({
)}
-
+
);
}
diff --git a/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx b/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx
index 2ba5dd847b..c30a4775d6 100644
--- a/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx
+++ b/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx
@@ -1,15 +1,18 @@
import * as Haptics from 'expo-haptics';
import { type Href, useRouter } from 'expo-router';
-import { Check } from 'lucide-react-native';
+import { Check, GitPullRequest } from 'lucide-react-native';
import { useRef, useState } from 'react';
-import { Pressable, ScrollView, TextInput, View } from 'react-native';
-import { toast } from 'sonner-native';
+import { Pressable, TextInput, View } from 'react-native';
import { matchesCodeReviewUrlSuffix } from '@kilocode/app-shared/code-review';
import { ModelSelector } from '@/components/agents/model-selector';
+import { EmptyState } from '@/components/empty-state';
+import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
import { Button } from '@/components/ui/button';
+import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
+import { TabScreenScrollView } from '@/components/tab-screen';
import { PLATFORM_CAPABILITIES } from '@/lib/code-reviewer-config';
import { useAvailableModels } from '@/lib/hooks/use-available-models';
import {
@@ -56,11 +59,14 @@ export function ManualReviewScreen({ scope }: Readonly<{ scope: string }>) {
const gitlabStatus = useGitLabStatus(scope);
const statusFor = { github: githubStatus, gitlab: gitlabStatus };
const isConnected = (option: ManualReviewPlatform) => statusFor[option].data?.connected === true;
+ const statusesLoading = githubStatus.isLoading || gitlabStatus.isLoading;
+ const statusesError = githubStatus.isError || gitlabStatus.isError;
const firstConnected = MANUAL_REVIEW_PLATFORMS.find(option => isConnected(option));
const [platformChoice, setPlatformChoice] = useState(null);
const platform = platformChoice ?? firstConnected ?? 'github';
const urlRef = useRef('');
const instructionsRef = useRef('');
+ const [urlError, setUrlError] = useState(null);
const config = useReviewConfig(scope, platform);
const createReview = useCreateManualReview(scope);
const { models } = useAvailableModels(scope === PERSONAL_SCOPE ? undefined : scope);
@@ -76,9 +82,10 @@ export function ManualReviewScreen({ scope }: Readonly<{ scope: string }>) {
const onSubmit = () => {
const url = urlRef.current.trim();
if (!isValidManualReviewUrl(platform, url)) {
- toast.error('Enter a valid pull request URL');
+ setUrlError('Enter a valid pull request URL');
return;
}
+ setUrlError(null);
if (!config.data) {
return;
}
@@ -101,12 +108,52 @@ export function ManualReviewScreen({ scope }: Readonly<{ scope: string }>) {
);
};
+ if (!statusesLoading && statusesError && !isConnected('github') && !isConnected('gitlab')) {
+ return (
+
+
+ {
+ void githubStatus.refetch();
+ void gitlabStatus.refetch();
+ }}
+ isRetrying={githubStatus.isRefetching || gitlabStatus.isRefetching}
+ />
+
+ );
+ }
+
+ if (!statusesLoading && !statusesError && !isConnected('github') && !isConnected('gitlab')) {
+ return (
+
+
+ {
+ router.push(`/(app)/(tabs)/(3_profile)/code-reviewer/${scope}/github` as Href);
+ }}
+ >
+ Connect GitHub
+
+ }
+ />
+
+ );
+ }
+
return (
-
-
+
@@ -114,43 +161,52 @@ export function ManualReviewScreen({ scope }: Readonly<{ scope: string }>) {
Platform
-
- {MANUAL_REVIEW_PLATFORMS.map((option, index) => {
- const connected = isConnected(option);
- return (
- {
- void Haptics.selectionAsync();
- urlRef.current = '';
- setPlatformChoice(option);
- }}
- >
-
-
- {PLATFORM_CAPABILITIES[option].label}
-
- {!connected && (
-
- Not connected
-
+ {statusesLoading ? (
+
+
+
+
+ ) : (
+
+ {MANUAL_REVIEW_PLATFORMS.map((option, index) => {
+ const connected = isConnected(option);
+ return (
+
-
-
- );
- })}
-
+ onPress={() => {
+ void Haptics.selectionAsync();
+ urlRef.current = '';
+ setUrlError(null);
+ setPlatformChoice(option);
+ }}
+ >
+
+
+ {PLATFORM_CAPABILITIES[option].label}
+
+ {!connected && (
+
+ Not connected
+
+ )}
+
+
+
+ );
+ })}
+
+ )}
@@ -167,8 +223,12 @@ export function ManualReviewScreen({ scope }: Readonly<{ scope: string }>) {
keyboardType="url"
onChangeText={value => {
urlRef.current = value;
+ if (urlError) {
+ setUrlError(null);
+ }
}}
/>
+ {urlError ? {urlError} : null}
@@ -205,12 +265,13 @@ export function ManualReviewScreen({ scope }: Readonly<{ scope: string }>) {
- Start review
+ {createReview.isPending ? 'Starting review…' : 'Start review'}
-
+
);
}
diff --git a/apps/mobile/src/components/code-reviewer/option-list.tsx b/apps/mobile/src/components/code-reviewer/option-list.tsx
index f704f23ec3..8cd51ba609 100644
--- a/apps/mobile/src/components/code-reviewer/option-list.tsx
+++ b/apps/mobile/src/components/code-reviewer/option-list.tsx
@@ -1,58 +1,77 @@
-import * as Haptics from 'expo-haptics';
import { useRouter } from 'expo-router';
-import { Check } from 'lucide-react-native';
-import { Pressable, ScrollView, View } from 'react-native';
+import { useState } from 'react';
+import { ActivityIndicator, View } from 'react-native';
import { ScreenHeader } from '@/components/screen-header';
-import { Text } from '@/components/ui/text';
+import { TabScreenScrollView } from '@/components/tab-screen';
+import { ChoiceRow } from '@/components/ui/choice-row';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
type OptionListProps = {
title: string;
options: readonly T[];
selected: T | undefined;
- onSelect: (value: T) => void;
+ /** Must resolve/reject once the save actually completes — the screen
+ * navigates back only on confirmed success. */
+ onSelect: (value: T) => Promise;
/** Optional per-option caption below the label. */
descriptions?: Readonly>;
+ /** Disables every row, e.g. while the config backing `selected` is still loading. */
+ disabled?: boolean;
};
-/** Full-screen single-select list. Selecting saves and pops the screen. */
+/** Full-screen single-select list. Selecting saves, then pops the screen only once the save confirms. */
export function OptionList({
title,
options,
selected,
onSelect,
descriptions,
+ disabled,
}: Readonly>) {
const router = useRouter();
const colors = useThemeColors();
+ const [pending, setPending] = useState(null);
+
+ const handleSelect = async (option: T) => {
+ setPending(option);
+ try {
+ await onSelect(option);
+ router.back();
+ } catch {
+ // The save hook already surfaces the failure (toast + cache
+ // rollback) — just stop showing this row as pending so the user can
+ // retry or pick something else.
+ } finally {
+ setPending(null);
+ }
+ };
return (
-
+
{options.map(option => (
- {
- void Haptics.selectionAsync();
- onSelect(option);
- router.back();
- }}
- >
-
- {option}
- {descriptions?.[option] ? (
-
- {descriptions[option]}
-
- ) : null}
-
-
-
+
+ {
+ void handleSelect(option);
+ }}
+ className="border-b-[0.5px] border-hair-soft"
+ />
+ {pending === option && (
+
+
+
+ )}
+
))}
-
+
);
}
diff --git a/apps/mobile/src/components/code-reviewer/platform-list-screen.tsx b/apps/mobile/src/components/code-reviewer/platform-list-screen.tsx
index f77366331e..154485c022 100644
--- a/apps/mobile/src/components/code-reviewer/platform-list-screen.tsx
+++ b/apps/mobile/src/components/code-reviewer/platform-list-screen.tsx
@@ -1,11 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { type Href, useRouter } from 'expo-router';
import { CirclePlus, GitBranch, GitMerge, GitPullRequest, History } from 'lucide-react-native';
-import { ScrollView, View } from 'react-native';
+import { View } from 'react-native';
import { ScreenHeader } from '@/components/screen-header';
import { ConfigureRow } from '@/components/ui/configure-row';
import { Text } from '@/components/ui/text';
+import { TabScreenScrollView } from '@/components/tab-screen';
import { PLATFORM_CAPABILITIES, type ReviewerPlatform } from '@/lib/code-reviewer-config';
import {
PERSONAL_SCOPE,
@@ -24,8 +25,11 @@ const PLATFORM_ICONS: Record = {
const ALL_PLATFORMS = ['github', 'gitlab', 'bitbucket'] as const;
function connectionSubtitle(status: { isLoading: boolean; data?: { connected: boolean } }) {
+ // Reserve the subtitle line with a placeholder while loading instead of
+ // omitting it — otherwise the row grows by a line once the real status
+ // arrives, popping the layout.
if (status.isLoading) {
- return undefined;
+ return 'Checking…';
}
return status.data?.connected ? 'Connected' : 'Not connected';
}
@@ -64,7 +68,7 @@ export function PlatformListScreen({ scope }: Readonly<{ scope: string }>) {
return (
-
+
Platforms
@@ -114,7 +118,7 @@ export function PlatformListScreen({ scope }: Readonly<{ scope: string }>) {
/>
-
+
);
}
diff --git a/apps/mobile/src/components/code-reviewer/platform-overview-rows.ts b/apps/mobile/src/components/code-reviewer/platform-overview-rows.ts
new file mode 100644
index 0000000000..348fb29556
--- /dev/null
+++ b/apps/mobile/src/components/code-reviewer/platform-overview-rows.ts
@@ -0,0 +1,107 @@
+import {
+ FileSliders,
+ FolderGit2,
+ Gauge,
+ type LucideIcon,
+ MessageSquareText,
+ ScrollText,
+ ShieldCheck,
+} from 'lucide-react-native';
+
+import { type PLATFORM_CAPABILITIES, type ReviewConfigData } from '@/lib/code-reviewer-config';
+import { type ModelOption } from '@/lib/hooks/use-available-models';
+import { capitalize } from '@/lib/utils';
+
+type OverviewRow = {
+ field: string;
+ icon: LucideIcon;
+ title: string;
+ subtitle: string;
+ onPress?: () => void;
+};
+
+/**
+ * Config-derived rows shown on a connected provider's overview screen.
+ * Pulled out of platform-overview-screen.tsx purely to keep that file under
+ * the repo's max-lines limit — no behavior change.
+ */
+export function buildOverviewRows({
+ data,
+ capabilities,
+ models,
+ modelsLoading,
+ onOpenModelPicker,
+}: {
+ data: ReviewConfigData;
+ capabilities: (typeof PLATFORM_CAPABILITIES)[keyof typeof PLATFORM_CAPABILITIES];
+ models: ModelOption[];
+ modelsLoading: boolean;
+ onOpenModelPicker: () => void;
+}): OverviewRow[] {
+ return [
+ {
+ field: 'style',
+ icon: MessageSquareText,
+ title: 'Review Style',
+ subtitle: capitalize(data.reviewStyle),
+ },
+ {
+ field: 'focus-areas',
+ icon: ShieldCheck,
+ title: 'Focus Areas',
+ subtitle:
+ data.focusAreas.length > 0 ? data.focusAreas.map(capitalize).join(', ') : 'All areas',
+ },
+ {
+ field: 'instructions',
+ icon: ScrollText,
+ title: 'Custom Instructions',
+ subtitle: data.customInstructions ? 'Set' : 'None',
+ },
+ {
+ field: 'model',
+ icon: FileSliders,
+ title: 'Model',
+ subtitle: models.find(model => model.id === data.modelSlug)?.name ?? data.modelSlug,
+ onPress: modelsLoading || models.length === 0 ? undefined : onOpenModelPicker,
+ },
+ ...(capabilities.gateRow
+ ? [
+ {
+ field: 'gate',
+ icon: Gauge,
+ title: 'Merge gate',
+ subtitle: capitalize(data.gateThreshold),
+ },
+ ]
+ : []),
+ {
+ field: 'repos',
+ icon: FolderGit2,
+ title: 'Repositories',
+ subtitle:
+ capabilities.selectionModePicker && data.repositorySelectionMode === 'all'
+ ? 'All repositories'
+ : `${data.selectedRepositoryIds.length} selected`,
+ },
+ ];
+}
+
+/** Shared onPress resolution for an overview row: no-op when read-only, the
+ * row's own handler (e.g. the model picker) when it has one, otherwise a
+ * push to its settings field. */
+export function resolveRowOnPress(
+ row: OverviewRow,
+ canEdit: boolean,
+ pushField: (field: string) => void
+): (() => void) | undefined {
+ if (!canEdit) {
+ return undefined;
+ }
+ if ('onPress' in row) {
+ return row.onPress;
+ }
+ return () => {
+ pushField(row.field);
+ };
+}
diff --git a/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx b/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx
index 5889c26a29..78a6a46348 100644
--- a/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx
+++ b/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx
@@ -1,163 +1,170 @@
import * as Haptics from 'expo-haptics';
import { type Href, useRouter } from 'expo-router';
-import {
- FileSliders,
- FolderGit2,
- Gauge,
- MessageSquareText,
- ScrollText,
- ShieldCheck,
-} from 'lucide-react-native';
-import { ScrollView, Switch, View } from 'react-native';
+import { ActivityIndicator, Switch, View } from 'react-native';
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
import { openModelPicker } from '@/components/agents/model-selector';
import { BitbucketOverview } from '@/components/code-reviewer/bitbucket-overview';
+import {
+ buildOverviewRows,
+ resolveRowOnPress,
+} from '@/components/code-reviewer/platform-overview-rows';
import { ProviderConnectCard } from '@/components/code-reviewer/provider-connect-card';
+import { PlatformErrorScreen } from '@/components/platform-error-screen';
import { ScreenHeader } from '@/components/screen-header';
+import { Button } from '@/components/ui/button';
import { ConfigureRow } from '@/components/ui/configure-row';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
+import { TabScreenScrollView } from '@/components/tab-screen';
import { PLATFORM_CAPABILITIES, type ReviewerPlatform } from '@/lib/code-reviewer-config';
import { useAvailableModels } from '@/lib/hooks/use-available-models';
import {
+ classifyProviderState,
PERSONAL_SCOPE,
- useCanEditReviewer,
useGitHubStatus,
useGitLabStatus,
+ useGitLabWebhookWarning,
useReviewConfig,
+ useReviewerPermission,
useSaveReviewConfig,
useToggleReviewer,
} from '@/lib/hooks/use-code-reviewer';
-
-const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1);
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
export function PlatformOverviewScreen({
scope,
platform,
}: Readonly<{ scope: string; platform: ReviewerPlatform }>) {
const router = useRouter();
+ const colors = useThemeColors();
+ const capabilities = PLATFORM_CAPABILITIES[platform];
const githubStatus = useGitHubStatus(scope);
const gitlabStatus = useGitLabStatus(scope);
const config = useReviewConfig(scope, platform);
const toggle = useToggleReviewer(scope, platform);
const save = useSaveReviewConfig(scope, platform);
- const canEdit = useCanEditReviewer(scope);
+ const permission = useReviewerPermission(scope);
+ const canEdit = permission.status === 'ready' && permission.canEdit;
+ const { hasWebhookSyncWarning } = useGitLabWebhookWarning(scope, platform);
const { models, isLoading: modelsLoading } = useAvailableModels(
scope === PERSONAL_SCOPE ? undefined : scope
);
- if (platform === 'bitbucket' && scope === PERSONAL_SCOPE) {
+ if (permission.status === 'error') {
return (
-
-
-
-
- Bitbucket is available for organizations only.
-
-
-
+ {
+ permission.refetch();
+ }}
+ isRetrying={permission.isRetrying}
+ />
);
}
if (platform === 'bitbucket') {
- return ;
+ return (
+
+ );
}
- const capabilities = PLATFORM_CAPABILITIES[platform];
const status = platform === 'gitlab' ? gitlabStatus : githubStatus;
- const isLoading = status.isLoading || config.isLoading;
- const connected = status.data?.connected === true;
+ const providerState = classifyProviderState({
+ isLoading: status.isLoading,
+ isError: status.isError,
+ isFetching: status.isFetching,
+ connected: status.data?.connected,
+ hasData: status.data !== undefined,
+ refetch: () => void status.refetch(),
+ });
+
+ if (providerState.status === 'error') {
+ return (
+ {
+ providerState.refetch();
+ }}
+ isRetrying={providerState.isRetrying}
+ />
+ );
+ }
+
+ const isLoading =
+ providerState.status === 'loading' || config.isLoading || permission.status === 'loading';
+ const connected = providerState.status === 'connected';
+
+ // A connected provider whose config fails to load (and has no stale cache
+ // to fall back on) has nothing to show — surface a retry instead of a
+ // header over blank space. A background refetch failure with data already
+ // cached falls through to the normal content below, unaffected.
+ if (!isLoading && connected && config.isError && config.data == null) {
+ return (
+ {
+ void config.refetch();
+ }}
+ isRetrying={config.isFetching}
+ />
+ );
+ }
const pushField = (field: string) => {
router.push(`/(app)/(tabs)/(3_profile)/code-reviewer/${scope}/${platform}/${field}` as Href);
};
const data = config.data;
+ // Repositories must actually be in scope before automatic reviews can run
+ // "blind" — 'all' mode always qualifies, 'selected' mode needs at least
+ // one chosen repo. Only blocks turning the toggle ON; an already-enabled
+ // reviewer stays togglable off even if selection later becomes empty.
+ const hasRepoSelection =
+ data != null &&
+ (data.repositorySelectionMode === 'all' || data.selectedRepositoryIds.length > 0);
const rows =
data == null
? null
- : [
- {
- field: 'style',
- icon: MessageSquareText,
- title: 'Review Style',
- subtitle: capitalize(data.reviewStyle),
- },
- {
- field: 'focus-areas',
- icon: ShieldCheck,
- title: 'Focus Areas',
- subtitle:
- data.focusAreas.length > 0 ? data.focusAreas.map(capitalize).join(', ') : 'All areas',
+ : buildOverviewRows({
+ data,
+ capabilities,
+ models,
+ modelsLoading,
+ onOpenModelPicker: () => {
+ openModelPicker(router, {
+ options: models,
+ value: data.modelSlug,
+ variant: data.thinkingEffort ?? '',
+ onSelect: (modelSlug, variant) => {
+ save.mutate({ modelSlug, thinkingEffort: variant || null });
+ },
+ });
},
- {
- field: 'instructions',
- icon: ScrollText,
- title: 'Custom Instructions',
- subtitle: data.customInstructions ? 'Set' : 'None',
- },
- {
- field: 'model',
- icon: FileSliders,
- title: 'Model',
- subtitle: models.find(model => model.id === data.modelSlug)?.name ?? data.modelSlug,
- onPress:
- modelsLoading || models.length === 0
- ? undefined
- : () => {
- openModelPicker(router, {
- options: models,
- value: data.modelSlug,
- variant: data.thinkingEffort ?? '',
- onSelect: (modelSlug, variant) => {
- save.mutate({ modelSlug, thinkingEffort: variant || null });
- },
- });
- },
- },
- ...(capabilities.gateRow
- ? [
- {
- field: 'gate',
- icon: Gauge,
- title: 'Merge Gate',
- subtitle: capitalize(data.gateThreshold),
- },
- ]
- : []),
- {
- field: 'repos',
- icon: FolderGit2,
- title: 'Repositories',
- subtitle:
- capabilities.selectionModePicker && data.repositorySelectionMode === 'all'
- ? 'All repositories'
- : `${data.selectedRepositoryIds.length} selected`,
- },
- ];
-
- const resolveRowOnPress = (row: NonNullable[number]) => {
- if (!canEdit) {
- return undefined;
- }
- if ('onPress' in row) {
- return row.onPress;
- }
- return () => {
- pushField(row.field);
- };
- };
+ });
return (
-
+
{isLoading && (
-
-
+
+
+ {Array.from({ length: 6 }, (_, index) => (
+
+ ))}
+
)}
@@ -181,21 +188,67 @@ export function PlatformOverviewScreen({
{!isLoading && connected && config.data != null && rows != null && (
-
-
- Automatic reviews
-
- {status.data?.integration?.accountLogin ?? ''}
-
+ {platform === 'gitlab' && hasWebhookSyncWarning && (
+
+
+ Webhook setup incomplete
+
+ Some repositories may not receive automatic reviews.
+
+
+ {
+ save.mutate({});
+ }}
+ >
+ {save.isPending ? (
+
+ ) : null}
+ Retry
+
- {
- void Haptics.selectionAsync();
- toggle.mutate({ isEnabled: value });
- }}
- />
+ )}
+
+
+
+
+ Automatic reviews
+
+ {status.data?.integration?.accountLogin ?? ''}
+
+
+ {
+ void Haptics.selectionAsync();
+ toggle.mutate({ isEnabled: value });
+ }}
+ />
+
+ {!hasRepoSelection && (
+
+
+ Select at least one repository to enable automatic reviews.
+
+ {
+ pushField('repos');
+ }}
+ >
+ Select repositories
+
+
+ )}
@@ -206,7 +259,7 @@ export function PlatformOverviewScreen({
title={row.title}
subtitle={row.subtitle}
last={index === rows.length - 1}
- onPress={resolveRowOnPress(row)}
+ onPress={resolveRowOnPress(row, canEdit, pushField)}
/>
))}
@@ -238,7 +291,7 @@ export function PlatformOverviewScreen({
)}
-
+
);
}
diff --git a/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx b/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx
index e01a765c6a..e3bd9ed854 100644
--- a/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx
+++ b/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx
@@ -1,16 +1,20 @@
import * as Haptics from 'expo-haptics';
-import { Alert, Linking, Pressable, ScrollView, View } from 'react-native';
+import { Alert, View } from 'react-native';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import {
isCancellableReviewStatus,
isRetriggerableReviewStatus,
} from '@kilocode/app-shared/code-review';
+import { formatDollars, fromMicrodollars } from '@kilocode/app-shared/utils';
import { statusMeta } from '@/components/code-reviewer/review-list-screen';
+import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
+import { TabScreenScrollView } from '@/components/tab-screen';
+import { openExternalUrl } from '@/lib/external-link';
import { useCancelReview, useRetriggerReview, useReviewDetail } from '@/lib/hooks/use-code-reviews';
import { cn, parseTimestamp, timeAgo } from '@/lib/utils';
@@ -47,56 +51,53 @@ export function ReviewDetailScreen({
scope,
reviewId,
}: Readonly<{ scope: string; reviewId: string }>) {
- const { data, isLoading, isError, error, refetch } = useReviewDetail(reviewId);
+ const { data, isLoading, isError, isFetching, error, refetch } = useReviewDetail(reviewId);
const cancelReview = useCancelReview(scope);
const retriggerReview = useRetriggerReview(scope);
- if (isError) {
+ if (isLoading) {
return (
-
- {
- void refetch();
- }}
- >
- {error.message}. Tap to retry.
-
-
-
- );
- }
-
- if (isLoading || !data) {
- return (
-
-
-
+
-
+
);
}
- if (!data.success) {
+ // A thrown NOT_FOUND/FORBIDDEN/UNAUTHORIZED can never be fixed by
+ // retrying — show a plain message with no "Retry" affordance.
+ // UNAUTHORIZED is what org-scoped reviews throw via ensureOrganizationAccess,
+ // so it needs the same permanent classification as FORBIDDEN. Any other
+ // thrown error (or a resolved `success: false`, the router's
+ // generic-failure shape) is treated as transient and gets a retry button.
+ if (!data || !data.success) {
+ const errorCode = isError ? error.data?.code : undefined;
+ if (errorCode === 'NOT_FOUND' || errorCode === 'FORBIDDEN' || errorCode === 'UNAUTHORIZED') {
+ return (
+
+
+
+
+
+
+ );
+ }
return (
-
- {
- void refetch();
- }}
- >
- {data.error}. Tap to retry.
-
-
+
+ void refetch()}
+ isRetrying={isFetching}
+ />
+
);
}
@@ -109,7 +110,16 @@ export function ReviewDetailScreen({
return (
-
+
+ {/* A background poll failure must not blank out an already-loaded
+ review — keep showing the stale detail with a non-blocking note
+ instead of replacing it with a full error screen. */}
+ {isError && (
+
+ Couldn't refresh — showing the last known status.
+
+ )}
+
{review.pr_title}
@@ -130,7 +140,7 @@ export function ReviewDetailScreen({
) : null}
{review.total_cost_musd != null && review.total_cost_musd > 0 ? (
-
+
) : null}
{tokenUsage.input > 0 || tokenUsage.output > 0 ? (
@@ -138,7 +148,7 @@ export function ReviewDetailScreen({
{review.error_message ? (
-
+
{review.error_message}
) : null}
@@ -147,7 +157,7 @@ export function ReviewDetailScreen({
{
- void Linking.openURL(review.pr_url);
+ void openExternalUrl(review.pr_url, { label: 'pull request' });
}}
>
Open pull request
@@ -195,7 +205,7 @@ export function ReviewDetailScreen({
) : null}
-
+
);
}
diff --git a/apps/mobile/src/components/code-reviewer/review-list-screen.tsx b/apps/mobile/src/components/code-reviewer/review-list-screen.tsx
index fb6deb3122..608b3ba325 100644
--- a/apps/mobile/src/components/code-reviewer/review-list-screen.tsx
+++ b/apps/mobile/src/components/code-reviewer/review-list-screen.tsx
@@ -1,6 +1,6 @@
import { type Href, useRouter } from 'expo-router';
import { GitPullRequest } from 'lucide-react-native';
-import { Pressable, ScrollView, View } from 'react-native';
+import { Pressable, View } from 'react-native';
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
import {
@@ -9,9 +9,13 @@ import {
isCodeReviewStatus,
} from '@kilocode/app-shared/code-review';
import { EmptyState } from '@/components/empty-state';
+import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
+import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
+import { TabScreenScrollView } from '@/components/tab-screen';
+import { useGitHubStatus, useGitLabStatus } from '@/lib/hooks/use-code-reviewer';
import { useReviewList } from '@/lib/hooks/use-code-reviews';
import { cn, parseTimestamp, timeAgo } from '@/lib/utils';
@@ -20,11 +24,11 @@ import { cn, parseTimestamp, timeAgo } from '@/lib/utils';
const STATUS_CLASSNAME: Record = {
pending: 'text-muted-foreground',
queued: 'text-muted-foreground',
- running: 'text-blue-600 dark:text-blue-400',
- completed: 'text-green-600 dark:text-green-400',
+ running: 'text-info',
+ completed: 'text-good',
failed: 'text-destructive',
cancelled: 'text-muted-foreground',
- interrupted: 'text-amber-600 dark:text-amber-400',
+ interrupted: 'text-warn',
};
type ReviewListData = NonNullable['data']>;
@@ -43,12 +47,16 @@ function reviewTime(review: Review): Date {
export function ReviewListScreen({ scope }: Readonly<{ scope: string }>) {
const router = useRouter();
- const { data, isLoading, isError, error, refetch } = useReviewList(scope);
+ const { data, isLoading, isError, isFetching, refetch } = useReviewList(scope);
+ const githubStatus = useGitHubStatus(scope);
+ const gitlabStatus = useGitLabStatus(scope);
+ const hasConnectedProvider =
+ githubStatus.data?.connected === true || gitlabStatus.data?.connected === true;
return (
-
-
+
+
{isLoading && (
@@ -62,33 +70,47 @@ export function ReviewListScreen({ scope }: Readonly<{ scope: string }>) {
background poll failure with stale data should keep showing that data,
not hide it behind a retry banner. */}
{!isLoading && isError && !data && (
- {
- void refetch();
- }}
- >
- {error.message}. Tap to retry.
-
+ void refetch()}
+ isRetrying={isFetching}
+ />
)}
{!isLoading && data && !data.success && (
- {
- void refetch();
- }}
- >
- {data.error}. Tap to retry.
-
+ void refetch()}
+ isRetrying={isFetching}
+ />
)}
{!isLoading && data?.success && data.reviews.length === 0 && (
{
+ router.push(
+ (hasConnectedProvider
+ ? `/(app)/(tabs)/(3_profile)/code-reviewer/${scope}/manual-review`
+ : `/(app)/(tabs)/(3_profile)/code-reviewer/${scope}`) as Href
+ );
+ }}
+ >
+
+ {hasConnectedProvider ? 'Start a manual review' : 'Configure provider'}
+
+
+ }
/>
)}
@@ -128,7 +150,7 @@ export function ReviewListScreen({ scope }: Readonly<{ scope: string }>) {
)}
-
+
);
}
diff --git a/apps/mobile/src/components/code-reviewer/scope-list-screen.tsx b/apps/mobile/src/components/code-reviewer/scope-list-screen.tsx
index b312fb0e01..16656d09a3 100644
--- a/apps/mobile/src/components/code-reviewer/scope-list-screen.tsx
+++ b/apps/mobile/src/components/code-reviewer/scope-list-screen.tsx
@@ -1,18 +1,26 @@
import { useQuery } from '@tanstack/react-query';
import { type Href, useRouter } from 'expo-router';
import { Building2, User } from 'lucide-react-native';
-import { ScrollView, View } from 'react-native';
+import { View } from 'react-native';
+import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
import { ConfigureRow } from '@/components/ui/configure-row';
import { Skeleton } from '@/components/ui/skeleton';
+import { TabScreenScrollView } from '@/components/tab-screen';
import { PERSONAL_SCOPE } from '@/lib/hooks/use-code-reviewer';
import { useTRPC } from '@/lib/trpc';
export function ScopeListScreen() {
const router = useRouter();
const trpc = useTRPC();
- const { data: orgs, isLoading } = useQuery(trpc.organizations.list.queryOptions());
+ const {
+ data: orgs,
+ isLoading,
+ isError,
+ isFetching,
+ refetch,
+ } = useQuery(trpc.organizations.list.queryOptions());
const openScope = (scope: string) => {
router.push(`/(app)/(tabs)/(3_profile)/code-reviewer/${scope}` as Href);
@@ -21,7 +29,18 @@ export function ScopeListScreen() {
return (
-
+
+ {isError && (
+ void refetch()}
+ isRetrying={isFetching}
+ />
+ )}
))
)}
-
+
);
}
diff --git a/apps/mobile/src/components/consent/consent-card.tsx b/apps/mobile/src/components/consent/consent-card.tsx
index 2a769162b2..8efd0346c2 100644
--- a/apps/mobile/src/components/consent/consent-card.tsx
+++ b/apps/mobile/src/components/consent/consent-card.tsx
@@ -1,9 +1,9 @@
import * as WebBrowser from 'expo-web-browser';
import { type Href, useRouter } from 'expo-router';
import { ChevronRight, MessageSquare, Shield, Smartphone, User } from 'lucide-react-native';
+import { useState } from 'react';
import { Alert, Platform, Pressable, ScrollView, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import { toast } from 'sonner-native';
import { ConsentRow } from '@/components/consent/consent-row';
import { type ConsentMode, getConsentActions } from '@/components/consent/consent-mode';
@@ -33,6 +33,8 @@ export function ConsentCard({ mode = 'onboarding' }: ConsentCardProps) {
paddingTop: 24,
paddingBottom: Math.max(bottom, 16) + (Platform.OS === 'android' ? 8 : 0),
};
+ const [pendingAction, setPendingAction] = useState<'primary' | 'secondary' | null>(null);
+ const [error, setError] = useState(null);
const handlePrimaryAction = async () => {
if (mode === 'review') {
@@ -41,12 +43,51 @@ export function ConsentCard({ mode = 'onboarding' }: ConsentCardProps) {
}
if (!userId) {
- toast.error('Could not load your account. Please try again.');
+ setError('Could not load your account. Please try again.');
return;
}
- await acceptConsent(userId);
- router.replace('/(app)/(tabs)' as Href);
+ setError(null);
+ setPendingAction('primary');
+ try {
+ await acceptConsent(userId);
+ router.replace('/(app)/(tabs)' as Href);
+ } catch {
+ setError('Could not save your consent. Please try again.');
+ setPendingAction(null);
+ }
+ };
+
+ const runSecondaryAction = async () => {
+ setError(null);
+ setPendingAction('secondary');
+
+ if (mode === 'review') {
+ if (!userId) {
+ setError('Could not load your account. Please try again.');
+ setPendingAction(null);
+ return;
+ }
+
+ try {
+ await revokeConsent(userId);
+ } catch {
+ setError('Could not revoke consent. Please try again.');
+ setPendingAction(null);
+ return;
+ }
+ }
+
+ try {
+ await signOut();
+ } catch {
+ setError(
+ mode === 'review'
+ ? 'Consent was revoked, but sign-out failed. Please try again.'
+ : 'Could not sign out. Please try again.'
+ );
+ setPendingAction(null);
+ }
};
const handleSecondaryAction = () => {
@@ -61,18 +102,7 @@ export function ConsentCard({ mode = 'onboarding' }: ConsentCardProps) {
text: actions.destructiveLabel,
style: 'destructive',
onPress: () => {
- void (async () => {
- if (mode === 'review') {
- if (!userId) {
- toast.error('Could not load your account. Please try again.');
- return;
- }
-
- await revokeConsent(userId);
- }
-
- await signOut();
- })();
+ void runSecondaryAction();
},
},
]);
@@ -144,6 +174,12 @@ export function ConsentCard({ mode = 'onboarding' }: ConsentCardProps) {
.
+ {error ? (
+
+ {error}
+
+ ) : null}
+
{
@@ -151,6 +187,8 @@ export function ConsentCard({ mode = 'onboarding' }: ConsentCardProps) {
}}
size="lg"
accessibilityLabel={actions.primaryLabel}
+ disabled={pendingAction === 'secondary'}
+ loading={pendingAction === 'primary'}
>
{actions.primaryLabel}
@@ -159,6 +197,8 @@ export function ConsentCard({ mode = 'onboarding' }: ConsentCardProps) {
size="lg"
onPress={handleSecondaryAction}
accessibilityLabel={actions.secondaryLabel}
+ disabled={pendingAction === 'primary'}
+ loading={pendingAction === 'secondary'}
>
{actions.secondaryLabel}
diff --git a/apps/mobile/src/components/consent/consent-details.tsx b/apps/mobile/src/components/consent/consent-details.tsx
index dbdce9b929..76a0de5ca3 100644
--- a/apps/mobile/src/components/consent/consent-details.tsx
+++ b/apps/mobile/src/components/consent/consent-details.tsx
@@ -49,8 +49,8 @@ export function ConsentDetails() {
why="Measure app performance and understand which channels bring new users."
who="The analytics provider named in our privacy policy."
footer={
-
-
+
+
No prompt or conversation content is sent to analytics.
diff --git a/apps/mobile/src/components/empty-state.tsx b/apps/mobile/src/components/empty-state.tsx
index 1530a3ca7a..696921372d 100644
--- a/apps/mobile/src/components/empty-state.tsx
+++ b/apps/mobile/src/components/empty-state.tsx
@@ -6,12 +6,21 @@ import { Text } from '@/components/ui/text';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { cn } from '@/lib/utils';
+const DEFAULT_ICON_CONTAINER_CLASS = 'h-14 w-14 rounded-2xl border border-border bg-card';
+
type EmptyStateProps = {
icon: LucideIcon;
title: string;
description: string;
className?: string;
action?: ReactNode;
+ placement?: 'center' | 'top';
+ /** Overrides the icon bubble's container classes (size/shape/background). Defaults to the card-style bubble. */
+ iconContainerClassName?: string;
+ iconSize?: number;
+ iconStrokeWidth?: number;
+ /** Set to 'header' when the title acts as the screen's heading (QueryError does). */
+ titleAccessibilityRole?: 'header';
};
export function EmptyState({
@@ -20,16 +29,29 @@ export function EmptyState({
description,
className,
action,
+ placement = 'center',
+ iconContainerClassName = DEFAULT_ICON_CONTAINER_CLASS,
+ iconSize = 24,
+ iconStrokeWidth = 1.5,
+ titleAccessibilityRole,
}: Readonly) {
const colors = useThemeColors();
return (
-
-
-
+
+
+
- {title}
+
+ {title}
+
{description}
diff --git a/apps/mobile/src/components/force-update-screen.tsx b/apps/mobile/src/components/force-update-screen.tsx
new file mode 100644
index 0000000000..223d3dcc14
--- /dev/null
+++ b/apps/mobile/src/components/force-update-screen.tsx
@@ -0,0 +1,60 @@
+import { Download } from 'lucide-react-native';
+import { useState } from 'react';
+import { Linking, Platform, View } from 'react-native';
+
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { openExternalUrl } from '@/lib/external-link';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+
+const STORE_URL =
+ Platform.OS === 'ios'
+ ? 'https://apps.apple.com/app/id6761193135'
+ : 'https://play.google.com/store/apps/details?id=com.kilocode.kiloapp';
+
+export function ForceUpdateScreen() {
+ const colors = useThemeColors();
+ const [storeOpenFailed, setStoreOpenFailed] = useState(false);
+
+ const openStore = async () => {
+ try {
+ await Linking.openURL(STORE_URL);
+ setStoreOpenFailed(false);
+ } catch {
+ setStoreOpenFailed(true);
+ }
+ };
+
+ return (
+
+
+ Update required
+
+ A new version of Kilo is available. Please update to continue.
+
+ void openStore()}>
+ Update now
+
+
+ {storeOpenFailed && (
+
+
+ Could not open the app store.
+
+ void openStore()}>
+ Try again
+
+ {
+ void openExternalUrl(STORE_URL, { label: 'app store page' });
+ }}
+ >
+ Open in browser
+
+
+ )}
+
+ );
+}
diff --git a/apps/mobile/src/components/home/agent-sessions-section.tsx b/apps/mobile/src/components/home/agent-sessions-section.tsx
index 11a7b6d71b..facdff5d18 100644
--- a/apps/mobile/src/components/home/agent-sessions-section.tsx
+++ b/apps/mobile/src/components/home/agent-sessions-section.tsx
@@ -5,8 +5,9 @@ import {
expandPlatformFilter,
formatGitUrlProject,
} from '@/components/agents/session-list-helpers';
-import { CompactSessionRow } from '@/components/home/compact-session-row';
import { SectionHeader } from '@/components/home/section-header';
+import { SessionRow } from '@/components/ui/session-row';
+import { Text } from '@/components/ui/text';
import {
type ActiveSession,
type StoredSession,
@@ -147,7 +148,7 @@ function storedSessionLabel(session: StoredSession): string {
export function AgentSessionsSection({ organizationId }: Readonly) {
const router = useRouter();
- const { activeSessions, storedSessions, activeSessionIds } = useAgentSessions({
+ const { activeSessions, storedSessions, activeSessionIds, activeIsError } = useAgentSessions({
organizationId,
});
@@ -173,6 +174,11 @@ export function AgentSessionsSection({ organizationId }: Readonly
+ {activeIsError ? (
+
+ Showing saved sessions — live status may be out of date
+
+ ) : null}
{rows.map(row => {
if (row.kind === 'active') {
@@ -182,10 +188,10 @@ export function AgentSessionsSection({ organizationId }: Readonly
- {
navigateTo(session.id);
@@ -200,11 +206,11 @@ export function AgentSessionsSection({ organizationId }: Readonly
- {
navigateTo(session.session_id, session.organization_id);
diff --git a/apps/mobile/src/components/home/compact-session-row.tsx b/apps/mobile/src/components/home/compact-session-row.tsx
deleted file mode 100644
index 68ff014dfe..0000000000
--- a/apps/mobile/src/components/home/compact-session-row.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { SessionRow } from '@/components/ui/session-row';
-
-type CompactSessionRowProps = {
- agentLabel: string;
- title: string;
- meta?: string;
- isLive: boolean;
- onPress: () => void;
- last?: boolean;
-};
-
-/**
- * Thin wrapper around the shared `SessionRow` primitive for Home-screen call
- * sites. Preserves the existing module export so any external imports keep
- * working.
- */
-export function CompactSessionRow({
- agentLabel,
- title,
- meta,
- isLive,
- onPress,
- last,
-}: Readonly) {
- return (
-
- );
-}
diff --git a/apps/mobile/src/components/home/greeting.tsx b/apps/mobile/src/components/home/greeting.tsx
index 8d9e02ac40..97112836f3 100644
--- a/apps/mobile/src/components/home/greeting.tsx
+++ b/apps/mobile/src/components/home/greeting.tsx
@@ -8,7 +8,7 @@ function timeOfDay(hour: number): 'morning' | 'afternoon' | 'evening' {
return 'evening';
}
-export function buildTimedGreeting(firstName: string | null): string {
+export function buildTimedGreeting(): string {
const period = timeOfDay(new Date().getHours());
- return firstName ? `Good ${period}, ${firstName}` : `Good ${period}`;
+ return `Good ${period}`;
}
diff --git a/apps/mobile/src/components/home/home-screen.test.ts b/apps/mobile/src/components/home/home-screen.test.ts
index 60ffb7fc59..5ad64e7ed8 100644
--- a/apps/mobile/src/components/home/home-screen.test.ts
+++ b/apps/mobile/src/components/home/home-screen.test.ts
@@ -47,9 +47,16 @@ vi.mock('@/components/kiloclaw/instance-card', () => ({
vi.mock('@/components/kiloclaw/status-badge', () => ({
isTransitionalStatus: () => false,
}));
+vi.mock('@/components/query-error', () => ({
+ QueryError: () => null,
+}));
vi.mock('@/components/screen-header', () => ({
ScreenHeader: () => null,
}));
+vi.mock('@/components/tab-screen', () => ({
+ TabScreenScrollView: 'ScrollView',
+ useTabBarBottomPadding: () => 0,
+}));
vi.mock('@/components/ui/skeleton', () => ({
Skeleton: () => null,
}));
diff --git a/apps/mobile/src/components/home/home-screen.tsx b/apps/mobile/src/components/home/home-screen.tsx
index f77bc40aa1..b9b59ce319 100644
--- a/apps/mobile/src/components/home/home-screen.tsx
+++ b/apps/mobile/src/components/home/home-screen.tsx
@@ -1,8 +1,10 @@
import { useQueryClient } from '@tanstack/react-query';
import { useFocusEffect, useIsFocused } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
-import { AppState, RefreshControl, ScrollView, View } from 'react-native';
-import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
+import { AppState, RefreshControl, View } from 'react-native';
+import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
+
+import { TabScreenScrollView } from '@/components/tab-screen';
import { badgeBucketForInstance } from '@kilocode/notifications';
@@ -14,6 +16,7 @@ import { NewTaskButton } from '@/components/home/new-task-button';
import { SectionHeader } from '@/components/home/section-header';
import { KiloClawCard } from '@/components/kiloclaw/instance-card';
import { isTransitionalStatus } from '@/components/kiloclaw/status-badge';
+import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
import { Skeleton } from '@/components/ui/skeleton';
import { useAgentSessions } from '@/lib/hooks/use-agent-sessions';
@@ -79,12 +82,16 @@ export function HomeScreen() {
data: instances,
isPending: instancesPending,
isError: instancesError,
+ refetch: refetchInstances,
} = useAllKiloClawInstances(pickListPollInterval);
const { byBadgeBucket: unreadByBadgeBucket } = useUnreadCounts();
const {
storedSessions,
activeSessions,
isLoading: sessionsLoading,
+ storedIsError,
+ storedIsSuccess,
+ refetch: refetchSessions,
} = useAgentSessions({
organizationId,
});
@@ -92,7 +99,7 @@ export function HomeScreen() {
const isLoading = instancesPending || sessionsLoading;
const hasAnySession = storedSessions.length > 0 || activeSessions.length > 0;
- const headerTitle = buildTimedGreeting(null);
+ const headerTitle = buildTimedGreeting();
const handleRefresh = useCallback(() => {
void (async () => {
@@ -108,38 +115,54 @@ export function HomeScreen() {
return (
- }
>
- {isLoading ? (
-
-
-
-
- ) : (
-
- {renderKiloClawSlot({
- instances: instances ?? [],
- instancesError,
- unreadByBadgeBucket,
- })}
-
- {renderSessionsOrPromo({
- hasAnySession,
- organizationId,
- })}
-
- {hasAnySession ? (
-
-
+
+ {isLoading ? (
+
+
+
+
+
+
+
+
+
- ) : null}
-
- )}
-
+
+
+
+
+
+ ) : (
+
+ {renderKiloClawSlot({
+ instances: instances ?? [],
+ instancesError,
+ handleRetryInstances: () => void refetchInstances(),
+ unreadByBadgeBucket,
+ })}
+
+ {renderSessionsOrPromo({
+ hasAnySession,
+ organizationId,
+ sessionsError: storedIsError,
+ sessionsLoadedEmpty: storedIsSuccess && !hasAnySession,
+ handleRetrySessions: () => void refetchSessions(),
+ })}
+
+ {hasAnySession ? (
+
+
+
+ ) : null}
+
+ )}
+
+
);
}
@@ -147,8 +170,12 @@ export function HomeScreen() {
function renderKiloClawSlot(params: {
instances: ClawInstance[];
instancesError: boolean;
+ handleRetryInstances: () => void;
unreadByBadgeBucket: Map;
}) {
+ // Stale data (a previously successful fetch) always wins over a
+ // background-refetch failure — only an initial-load failure with no
+ // instances at all should replace the section with an error state.
if (params.instances.length > 0) {
return (
@@ -168,14 +195,42 @@ function renderKiloClawSlot(params: {
);
}
if (params.instancesError) {
- return null;
+ return (
+
+ );
}
return ;
}
-function renderSessionsOrPromo(params: { hasAnySession: boolean; organizationId: string | null }) {
+function renderSessionsOrPromo(params: {
+ hasAnySession: boolean;
+ organizationId: string | null;
+ sessionsError: boolean;
+ sessionsLoadedEmpty: boolean;
+ handleRetrySessions: () => void;
+}) {
+ // Stale stored history always wins over an error (e.g. a live-poll blip
+ // on the active-sessions query) — never blank out sessions we already
+ // have. The first-use promo only appears after a confirmed empty
+ // response, never merely because the fetch hasn't succeeded yet.
if (params.hasAnySession) {
return ;
}
- return ;
+ if (params.sessionsError) {
+ return (
+
+ );
+ }
+ if (params.sessionsLoadedEmpty) {
+ return ;
+ }
+ return null;
}
diff --git a/apps/mobile/src/components/home/section-header.tsx b/apps/mobile/src/components/home/section-header.tsx
index 81ca926740..754ac5b965 100644
--- a/apps/mobile/src/components/home/section-header.tsx
+++ b/apps/mobile/src/components/home/section-header.tsx
@@ -14,7 +14,13 @@ export function SectionHeader({ label, actionLabel, onActionPress }: Readonly
{label}
{actionLabel && onActionPress ? (
-
+
{actionLabel}
diff --git a/apps/mobile/src/components/invalid-route-state.tsx b/apps/mobile/src/components/invalid-route-state.tsx
new file mode 100644
index 0000000000..b88b3d7d1d
--- /dev/null
+++ b/apps/mobile/src/components/invalid-route-state.tsx
@@ -0,0 +1,36 @@
+import { type Href, useRouter } from 'expo-router';
+import { SearchX } from 'lucide-react-native';
+import { View } from 'react-native';
+
+import { EmptyState } from '@/components/empty-state';
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+
+/**
+ * Terminal state for a route whose params fail runtime validation (bad
+ * scope/platform/id, or an unsupported scope+platform combination) —
+ * matches the "instance not found" pattern in instance-context-boundary.tsx.
+ */
+export function InvalidRouteState({ backTo }: Readonly<{ backTo: Href }>) {
+ const router = useRouter();
+
+ return (
+
+ {
+ router.replace(backTo);
+ }}
+ >
+ Go back
+
+ }
+ />
+
+ );
+}
diff --git a/apps/mobile/src/components/kilo-chat/bot-send-state.test.ts b/apps/mobile/src/components/kilo-chat/bot-send-state.test.ts
index ee755ac96f..8bd90518ee 100644
--- a/apps/mobile/src/components/kilo-chat/bot-send-state.test.ts
+++ b/apps/mobile/src/components/kilo-chat/bot-send-state.test.ts
@@ -5,7 +5,7 @@ import { resolveMobileMessageInputAvailability } from './bot-send-state';
const NOW = 1_000_000;
describe('mobile bot send gate', () => {
- it('blocks sends while bot status is unknown', () => {
+ it('blocks sends but hides the instance CTA while bot status is unknown (cold cache)', () => {
const state = resolveMobileMessageInputAvailability({
currentUserId: 'user-1',
instanceStatus: 'running',
@@ -17,30 +17,31 @@ describe('mobile bot send gate', () => {
expect(state.disabled).toBe(true);
expect(state.disabledReason).toBe('Waiting for bot status...');
+ expect(state.showInstanceCta).toBe(false);
});
it('blocks sends when the bot is offline or stale', () => {
- expect(
- resolveMobileMessageInputAvailability({
- currentUserId: 'user-1',
- instanceStatus: 'running',
- presence: { online: false, lastAt: NOW },
- now: NOW,
- pendingMutation: false,
- editing: false,
- }).disabled
- ).toBe(true);
+ const stoppedOffline = resolveMobileMessageInputAvailability({
+ currentUserId: 'user-1',
+ instanceStatus: 'running',
+ presence: { online: false, lastAt: NOW },
+ now: NOW,
+ pendingMutation: false,
+ editing: false,
+ });
+ expect(stoppedOffline.disabled).toBe(true);
+ expect(stoppedOffline.showInstanceCta).toBe(true);
- expect(
- resolveMobileMessageInputAvailability({
- currentUserId: 'user-1',
- instanceStatus: 'running',
- presence: { online: true, lastAt: NOW - 91_000 },
- now: NOW,
- pendingMutation: false,
- editing: false,
- }).disabled
- ).toBe(true);
+ const staleOffline = resolveMobileMessageInputAvailability({
+ currentUserId: 'user-1',
+ instanceStatus: 'running',
+ presence: { online: true, lastAt: NOW - 91_000 },
+ now: NOW,
+ pendingMutation: false,
+ editing: false,
+ });
+ expect(staleOffline.disabled).toBe(true);
+ expect(staleOffline.showInstanceCta).toBe(true);
});
it('allows sends when the bot is online or recently idle', () => {
@@ -93,6 +94,20 @@ describe('mobile bot send gate', () => {
expect(state.botDisplay.state).toBe('offline');
expect(state.disabled).toBe(true);
+ expect(state.showInstanceCta).toBe(true);
+ });
+
+ it('never shows the instance CTA while sends are allowed', () => {
+ expect(
+ resolveMobileMessageInputAvailability({
+ currentUserId: 'user-1',
+ instanceStatus: 'running',
+ presence: { online: true, lastAt: NOW - 10_000 },
+ now: NOW,
+ pendingMutation: false,
+ editing: false,
+ }).showInstanceCta
+ ).toBe(false);
});
it('keeps the composer enabled during pending sends when the bot can receive messages', () => {
diff --git a/apps/mobile/src/components/kilo-chat/bot-send-state.ts b/apps/mobile/src/components/kilo-chat/bot-send-state.ts
index a4a8b06120..53ca7207cb 100644
--- a/apps/mobile/src/components/kilo-chat/bot-send-state.ts
+++ b/apps/mobile/src/components/kilo-chat/bot-send-state.ts
@@ -14,6 +14,7 @@ type MessageInputAvailability = {
botDisplay: BotDisplay;
disabled: boolean;
disabledReason: string | null;
+ showInstanceCta: boolean;
submitDisabled: boolean;
};
@@ -60,6 +61,7 @@ export function resolveMobileMessageInputAvailability(params: {
botDisplay,
disabled: true,
disabledReason: 'Loading user...',
+ showInstanceCta: false,
submitDisabled: true,
};
}
@@ -69,6 +71,7 @@ export function resolveMobileMessageInputAvailability(params: {
botDisplay,
disabled: false,
disabledReason: null,
+ showInstanceCta: false,
submitDisabled: params.pendingMutation,
};
}
@@ -78,6 +81,7 @@ export function resolveMobileMessageInputAvailability(params: {
botDisplay,
disabled: false,
disabledReason: null,
+ showInstanceCta: false,
submitDisabled: params.pendingMutation,
};
}
@@ -89,6 +93,11 @@ export function resolveMobileMessageInputAvailability(params: {
botDisplay.state === 'unknown'
? 'Waiting for bot status...'
: 'Bot is offline. Messages will resume when it reconnects.',
+ // Only a confirmed 'offline' surfaces the CTA. 'unknown' is the cold-cache
+ // gap before the WS connects and the first bot-status round-trip resolves
+ // (see useBotStatus) — every conversation open passes through it, so
+ // treating it like offline fired the CTA on every open.
+ showInstanceCta: botDisplay.state === 'offline',
submitDisabled: true,
};
}
diff --git a/apps/mobile/src/components/kilo-chat/conversation-header.tsx b/apps/mobile/src/components/kilo-chat/conversation-header.tsx
index 1524228f2f..77aad572a9 100644
--- a/apps/mobile/src/components/kilo-chat/conversation-header.tsx
+++ b/apps/mobile/src/components/kilo-chat/conversation-header.tsx
@@ -5,7 +5,7 @@ import { ScreenHeader } from '@/components/screen-header';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
type Props = {
- title: string;
+ title?: string;
subtitle?: string;
canSwitchInstance?: boolean;
onSwitchInstance?: () => void;
diff --git a/apps/mobile/src/components/kilo-chat/conversation-history-state-views.tsx b/apps/mobile/src/components/kilo-chat/conversation-history-state-views.tsx
index ce62fa0ce8..f127cd6245 100644
--- a/apps/mobile/src/components/kilo-chat/conversation-history-state-views.tsx
+++ b/apps/mobile/src/components/kilo-chat/conversation-history-state-views.tsx
@@ -1,13 +1,14 @@
-import { View } from 'react-native';
+import { Pressable, View } from 'react-native';
import { QueryError } from '@/components/query-error';
import { Skeleton } from '@/components/ui/skeleton';
+import { Text } from '@/components/ui/text';
import { AppAwareKeyboardPaddingView } from './app-aware-keyboard-padding';
import { ConversationHeader } from './conversation-header';
type Props = {
- subtitle: string;
- title: string;
+ subtitle?: string;
+ title?: string;
};
export function ConversationHistoryLoadingView({ subtitle, title }: Props) {
@@ -16,9 +17,9 @@ export function ConversationHistoryLoadingView({ subtitle, title }: Props) {
-
-
-
+
+
+
@@ -26,22 +27,46 @@ export function ConversationHistoryLoadingView({ subtitle, title }: Props) {
}
export function ConversationHistoryErrorView({
+ message = 'Could not load conversation history',
onRetry,
subtitle,
title,
}: Props & {
+ message?: string;
onRetry: () => void;
}) {
return (
-
+
);
}
+
+/** Slim inline banner for a background/refetch failure while stale data is still shown. */
+export function ConversationInlineRetryBanner({
+ message,
+ onRetry,
+}: {
+ message: string;
+ onRetry: () => void;
+}) {
+ return (
+
+
+ {message}
+
+
+ Retry
+
+
+ );
+}
diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx
index ef63513dc8..1c0bb725cb 100644
--- a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx
+++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx
@@ -3,8 +3,15 @@ import { useBotStatus, useEventServiceClient } from '@kilocode/kilo-chat-hooks';
import * as Haptics from 'expo-haptics';
import { type Href, useRouter } from 'expo-router';
import { Plus, Settings2 } from 'lucide-react-native';
-import { useCallback, useMemo, useState } from 'react';
-import { ActivityIndicator, Pressable, RefreshControl, View, type ViewStyle } from 'react-native';
+import { useCallback, useMemo } from 'react';
+import {
+ ActivityIndicator,
+ Platform,
+ Pressable,
+ RefreshControl,
+ View,
+ type ViewStyle,
+} from 'react-native';
import Animated, { FadeIn } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -13,8 +20,10 @@ import { captureEvent, CONVERSATION_CREATED_EVENT } from '@/lib/analytics/postho
import { ScreenHeader } from '@/components/screen-header';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
+import { useManualRefresh } from '@/lib/hooks/use-manual-refresh';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { chatConversationPath } from '@/lib/kilo-chat-routes';
+import { getTabBarOverlayHeight } from '@/lib/tab-bar-layout';
import { EmptyConversationList } from './empty-conversation-list';
import { groupConversationsByActivity } from './conversation-list-groups';
@@ -47,14 +56,17 @@ type ConversationHeaderItem = {
type ConversationListEntry = ConversationHeaderItem | ConversationItem;
const listStyle = { flex: 1 } satisfies ViewStyle;
-const TAB_BAR_FAB_CLEARANCE = 72;
const FAB_SIZE = 56;
const FAB_MARGIN = 16;
function ConversationListSkeleton({ showHeader }: Readonly<{ showHeader?: boolean }>) {
return (
-
- {showHeader ? : null}
+
+ {showHeader ? (
+
+
+
+ ) : null}
{[0, 1, 2, 3].map(i => (
-
))}
@@ -96,27 +107,27 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) {
const createConversation = useCreateConversation(client);
const leaveConversation = useLeaveConversation(client);
const now = useNowTicker(60_000);
- const [manualRefreshing, setManualRefreshing] = useState(false);
const hasNextPage = listQuery.hasNextPage;
const isFetchingNextPage = listQuery.isFetchingNextPage;
const fetchNextPage = listQuery.fetchNextPage;
const refetchConversations = listQuery.refetch;
+ const tabBarOverlayHeight = getTabBarOverlayHeight(bottom, Platform.OS);
const listContentContainerStyle = useMemo(
() =>
({
flexGrow: 1,
- paddingBottom: Math.max(bottom, 16) + TAB_BAR_FAB_CLEARANCE + FAB_SIZE + FAB_MARGIN,
+ paddingBottom: tabBarOverlayHeight + FAB_SIZE + FAB_MARGIN,
}) satisfies ViewStyle,
- [bottom]
+ [tabBarOverlayHeight]
);
const createButtonStyle = useMemo(
() =>
({
- bottom: Math.max(bottom, 16) + TAB_BAR_FAB_CLEARANCE,
+ bottom: tabBarOverlayHeight + FAB_MARGIN,
right: 20,
}) satisfies ViewStyle,
- [bottom]
+ [tabBarOverlayHeight]
);
useInstancePresence(sandboxId);
@@ -155,16 +166,10 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) {
}
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
- const handleRefresh = useCallback(() => {
- void (async () => {
- setManualRefreshing(true);
- try {
- await refetchConversations();
- } finally {
- setManualRefreshing(false);
- }
- })();
- }, [refetchConversations]);
+ const [manualRefreshing, handleRefresh] = useManualRefresh(
+ refetchConversations,
+ 'Could not refresh. Showing the last saved list.'
+ );
const contentState = getConversationListContentState({
isPending: listQuery.isPending,
@@ -187,7 +192,11 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) {
return (
-
+
-
- {createConversation.isPending ? (
-
- ) : (
-
- )}
-
+ {/* The empty state below already renders its own "Create conversation" CTA —
+ only one creation affordance should be visible at a time. */}
+ {entries.length > 0 && (
+
+ {createConversation.isPending ? (
+
+ ) : (
+
+ )}
+
+ )}
);
}
diff --git a/apps/mobile/src/components/kilo-chat/conversation-route-state.test.ts b/apps/mobile/src/components/kilo-chat/conversation-route-state.test.ts
index 583911090a..6d8bd9bd00 100644
--- a/apps/mobile/src/components/kilo-chat/conversation-route-state.test.ts
+++ b/apps/mobile/src/components/kilo-chat/conversation-route-state.test.ts
@@ -1,39 +1,59 @@
import { KiloChatApiError } from '@kilocode/kilo-chat';
import { describe, expect, it } from 'vitest';
-import {
- getConversationRouteDecision,
- getConversationRouteErrorMessage,
- shouldRenderConversationScreen,
-} from './conversation-route-state';
+import { getConversationRouteDecision } from './conversation-route-state';
-describe('getConversationRouteErrorMessage', () => {
- it('uses the not-found message for forbidden conversation detail errors', () => {
- expect(getConversationRouteErrorMessage(new KiloChatApiError(403, {}))).toBe(
- 'Conversation not found'
- );
+describe('getConversationRouteDecision', () => {
+ it('is pending while the conversation detail is loading', () => {
+ expect(
+ getConversationRouteDecision({
+ detail: { data: undefined, error: undefined, isError: false },
+ routeSandboxId: 'sandbox-1',
+ })
+ ).toBe('pending');
});
- it('uses a generic message for non-API load failures', () => {
- expect(getConversationRouteErrorMessage(new Error('network down'))).toBe(
- 'Failed to load conversation'
- );
+ it('is ready after conversation detail loads successfully', () => {
+ expect(
+ getConversationRouteDecision({
+ detail: {
+ data: {
+ title: 'Kilo Chat',
+ members: [
+ { id: 'user-1', kind: 'user' },
+ { id: 'bot:kiloclaw:sandbox-1', kind: 'bot' },
+ ],
+ },
+ error: undefined,
+ isError: false,
+ },
+ routeSandboxId: 'sandbox-1',
+ })
+ ).toBe('ready');
});
-});
-describe('shouldRenderConversationScreen', () => {
- it('does not render while the conversation detail is loading', () => {
+ it('stays ready when a background refetch fails but cached data is retained', () => {
expect(
- shouldRenderConversationScreen({
- detail: { data: undefined, isError: false },
+ getConversationRouteDecision({
+ detail: {
+ data: {
+ title: 'Kilo Chat',
+ members: [
+ { id: 'user-1', kind: 'user' },
+ { id: 'bot:kiloclaw:sandbox-1', kind: 'bot' },
+ ],
+ },
+ error: new Error('network down'),
+ isError: true,
+ },
routeSandboxId: 'sandbox-1',
})
- ).toBe(false);
+ ).toBe('ready');
});
- it('renders after conversation detail loads successfully', () => {
+ it('redirects even with cached data when the background refetch confirms not-found/forbidden', () => {
expect(
- shouldRenderConversationScreen({
+ getConversationRouteDecision({
detail: {
data: {
title: 'Kilo Chat',
@@ -42,15 +62,13 @@ describe('shouldRenderConversationScreen', () => {
{ id: 'bot:kiloclaw:sandbox-1', kind: 'bot' },
],
},
- isError: false,
+ error: new KiloChatApiError(403, {}),
+ isError: true,
},
routeSandboxId: 'sandbox-1',
})
- ).toBe(true);
+ ).toBe('not-found');
});
-});
-
-describe('getConversationRouteDecision', () => {
it('rejects conversations that belong to a different sandbox route', () => {
expect(
getConversationRouteDecision({
@@ -62,10 +80,36 @@ describe('getConversationRouteDecision', () => {
{ id: 'bot:kiloclaw:sandbox-b', kind: 'bot' },
],
},
+ error: undefined,
isError: false,
},
routeSandboxId: 'sandbox-a',
})
).toBe('not-found');
});
+
+ it('redirects (not-found) for a confirmed forbidden/not-found API error', () => {
+ expect(
+ getConversationRouteDecision({
+ detail: { data: undefined, error: new KiloChatApiError(404, {}), isError: true },
+ routeSandboxId: 'sandbox-1',
+ })
+ ).toBe('not-found');
+ });
+
+ it('surfaces a retryable error in place for transport/server failures', () => {
+ expect(
+ getConversationRouteDecision({
+ detail: { data: undefined, error: new Error('network down'), isError: true },
+ routeSandboxId: 'sandbox-1',
+ })
+ ).toBe('retryable-error');
+
+ expect(
+ getConversationRouteDecision({
+ detail: { data: undefined, error: new KiloChatApiError(500, {}), isError: true },
+ routeSandboxId: 'sandbox-1',
+ })
+ ).toBe('retryable-error');
+ });
});
diff --git a/apps/mobile/src/components/kilo-chat/conversation-route-state.ts b/apps/mobile/src/components/kilo-chat/conversation-route-state.ts
index 26d1d6f1c1..d19b595792 100644
--- a/apps/mobile/src/components/kilo-chat/conversation-route-state.ts
+++ b/apps/mobile/src/components/kilo-chat/conversation-route-state.ts
@@ -6,17 +6,15 @@ import {
type ConversationRouteDetailState = {
data: { title: string | null; members: ConversationMember[] } | null | undefined;
+ error: unknown;
isError: boolean;
};
-type ConversationRouteDecision = 'pending' | 'ready' | 'error' | 'not-found';
+type ConversationRouteDecision = 'pending' | 'ready' | 'retryable-error' | 'not-found';
-export function getConversationRouteErrorMessage(error: unknown): string {
+function isConversationNotFoundError(error: unknown): boolean {
const status = error instanceof KiloChatApiError ? error.status : undefined;
- if (status === 400 || status === 403 || status === 404) {
- return 'Conversation not found';
- }
- return 'Failed to load conversation';
+ return status === 400 || status === 403 || status === 404;
}
export function getConversationRouteDecision({
@@ -26,24 +24,22 @@ export function getConversationRouteDecision({
detail: ConversationRouteDetailState;
routeSandboxId: string;
}): ConversationRouteDecision {
- if (detail.isError) {
- return 'error';
+ // Only a confirmed not-found/forbidden response redirects away — it's
+ // authoritative even over stale cached data (access was actually revoked).
+ if (detail.isError && isConversationNotFoundError(detail.error)) {
+ return 'not-found';
}
- if (detail.data === null || detail.data === undefined) {
- return 'pending';
+ // Cached data wins: a failed background refetch (isError true, data
+ // retained by TanStack) must not replace an already-rendered conversation
+ // with a full-screen error. Transport/server errors only take over the
+ // screen when there is no data to show yet.
+ if (detail.data !== null && detail.data !== undefined) {
+ return conversationSandboxIdFromMembers(detail.data.members) !== routeSandboxId
+ ? 'not-found'
+ : 'ready';
}
- if (conversationSandboxIdFromMembers(detail.data.members) !== routeSandboxId) {
- return 'not-found';
+ if (detail.isError) {
+ return 'retryable-error';
}
- return 'ready';
-}
-
-export function shouldRenderConversationScreen({
- detail,
- routeSandboxId,
-}: {
- detail: ConversationRouteDetailState;
- routeSandboxId: string;
-}): boolean {
- return getConversationRouteDecision({ detail, routeSandboxId }) === 'ready';
+ return 'pending';
}
diff --git a/apps/mobile/src/components/kilo-chat/conversation-row.tsx b/apps/mobile/src/components/kilo-chat/conversation-row.tsx
index 3b8b843568..ea2d8a1983 100644
--- a/apps/mobile/src/components/kilo-chat/conversation-row.tsx
+++ b/apps/mobile/src/components/kilo-chat/conversation-row.tsx
@@ -1,16 +1,18 @@
import { useActionSheet } from '@expo/react-native-action-sheet';
import { CONVERSATION_TITLE_MAX_CHARS, type ConversationListItem } from '@kilocode/kilo-chat';
import * as Haptics from 'expo-haptics';
-import { useRouter } from 'expo-router';
import { MessageSquare, MoreVertical } from 'lucide-react-native';
import { Alert, Pressable, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { RenameModal } from '@/components/rename-modal';
import { Text } from '@/components/ui/text';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
-import { chatRenameConversationPath } from '@/lib/kilo-chat-routes';
import { timeAgo } from '@/lib/utils';
+import { useConversationRename } from './hooks/use-conversation-rename';
+import { useKiloChatClient } from './hooks/use-kilo-chat-client';
+
type ConversationRowProps = {
conversation: ConversationListItem;
sandboxId: string;
@@ -39,20 +41,17 @@ export function ConversationRow({
onPress,
onLeave,
}: Readonly) {
- const router = useRouter();
const colors = useThemeColors();
const { bottom } = useSafeAreaInsets();
const { showActionSheetWithOptions } = useActionSheet();
+ const client = useKiloChatClient();
+ const { renaming, openRename, closeRename, saveRename } = useConversationRename(
+ client,
+ conversation.conversationId,
+ sandboxId
+ );
const title = conversationTitle(conversation);
- function openRenameSheet() {
- const params = new URLSearchParams({
- conversationId: conversation.conversationId,
- title: (conversation.title ?? '').slice(0, CONVERSATION_TITLE_MAX_CHARS),
- });
- router.push(chatRenameConversationPath(sandboxId, params));
- }
-
function confirmLeave() {
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
Alert.alert('Leave conversation?', 'This removes it from your list.', [
@@ -79,7 +78,7 @@ export function ConversationRow({
},
index => {
if (index === 0) {
- openRenameSheet();
+ openRename();
} else if (index === 1) {
confirmLeave();
}
@@ -88,46 +87,58 @@ export function ConversationRow({
}
return (
- {
- onPress(conversation.conversationId);
- }}
- onLongPress={openActions}
- >
-
-
-
-
-
-
- {title}
-
-
-
- {hasUnread(conversation) ? (
-
- ) : null}
-
- {timeAgo(new Date(conversationTimestamp(conversation)))}
-
-
-
+ <>
{
+ onPress(conversation.conversationId);
+ }}
+ onLongPress={openActions}
>
-
+
+
+
+
+
+
+ {title}
+
+
+
+ {hasUnread(conversation) ? (
+
+ ) : null}
+
+ {timeAgo(new Date(conversationTimestamp(conversation)))}
+
+
+
+
+
+
-
+ {renaming && (
+
+ )}
+ >
);
}
diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx
index a59cb90688..6d62c0f3d5 100644
--- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx
+++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx
@@ -1,18 +1,18 @@
-import { useActionSheet } from '@expo/react-native-action-sheet';
-import * as Haptics from 'expo-haptics';
import { useBotStatus, useEventServiceClient } from '@kilocode/kilo-chat-hooks';
-import { type ConversationDetailResponse } from '@kilocode/kilo-chat';
+import { CONVERSATION_TITLE_MAX_CHARS, type ConversationDetailResponse } from '@kilocode/kilo-chat';
import { useCallback } from 'react';
-import { Alert, View } from 'react-native';
-import { useFocusEffect, useRouter } from 'expo-router';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { View } from 'react-native';
+import { type Href, useFocusEffect, useRouter } from 'expo-router';
import { toast } from 'sonner-native';
+import { RenameModal } from '@/components/rename-modal';
+
import { AppAwareKeyboardPaddingView } from './app-aware-keyboard-padding';
import { ConversationHeader } from './conversation-header';
import {
ConversationHistoryErrorView,
ConversationHistoryLoadingView,
+ ConversationInlineRetryBanner,
} from './conversation-history-state-views';
import { MessageInput } from './message-input';
import { MessageList } from './message-list';
@@ -20,7 +20,7 @@ import { MessageReactionPickerSheet } from './message-reaction-picker-sheet';
import { getMessageHistoryContentState } from './message-history-state';
import { useConversationPresence } from './hooks/use-conversation-presence';
import { useConversationEventSubscription } from './hooks/use-conversation-event-subscription';
-import { useLeaveConversation } from './hooks/use-conversations';
+import { useConversationOptionsSheet } from './hooks/use-conversation-options-sheet';
import { useMobileTypingState, useTypingSender } from './hooks/use-typing';
import { useKiloChatClient } from './hooks/use-kilo-chat-client';
import { useConversationMarkRead } from './hooks/use-conversation-mark-read';
@@ -28,14 +28,15 @@ import { useConversationMessageController } from './hooks/use-conversation-messa
import { useMessageCacheUpdater, useMessages } from './hooks/use-messages';
import { useNowTicker } from './hooks/use-now-ticker';
import { useCurrentUserId } from './hooks/use-current-user-id';
-import { useAllKiloClawInstances, useInstanceContext } from '@/lib/hooks/use-instance-context';
+import { useKiloChatTokenError } from './kilo-chat-provider';
+import {
+ instanceOrgId,
+ useAllKiloClawInstances,
+ useInstanceContext,
+} from '@/lib/hooks/use-instance-context';
import { useKiloClawStatus } from '@/lib/hooks/use-kiloclaw-queries';
import { kiloclawConversationEyebrow } from '@/lib/kiloclaw-display';
-import {
- chatInstancePickerPath,
- chatRenameConversationPath,
- chatSandboxPath,
-} from '@/lib/kilo-chat-routes';
+import { chatInstancePickerPath } from '@/lib/kilo-chat-routes';
import { setActiveChatLocation } from '@/lib/notifications';
type Props = {
@@ -57,12 +58,11 @@ export function ConversationScreen({
const eventClient = useEventServiceClient();
const router = useRouter();
const currentUserId = useCurrentUserId();
- const { showActionSheetWithOptions } = useActionSheet();
- const { bottom } = useSafeAreaInsets();
+ const tokenError = useKiloChatTokenError();
const instanceContext = useInstanceContext(sandboxId);
const instanceStatusQuery = useKiloClawStatus(
- instanceContext.organizationId,
- instanceContext.isResolved
+ instanceOrgId(instanceContext),
+ instanceContext.status === 'ready'
);
const { data: instances } = useAllKiloClawInstances();
const currentInstance = instances?.find(instance => instance.sandboxId === sandboxId);
@@ -78,7 +78,8 @@ export function ConversationScreen({
isError: messagesQuery.isError,
hasData: messagesQuery.data !== undefined,
});
- const hasInitialMessages = messageHistoryState === 'ready';
+ const hasInitialMessages =
+ messageHistoryState === 'ready' || messageHistoryState === 'stale-error';
const messages = hasInitialMessages ? (messagesQuery.data?.messages ?? []) : [];
const fetchOlder = useCallback(() => {
if (messagesQuery.hasNextPage && !messagesQuery.isFetchingNextPage) {
@@ -86,7 +87,12 @@ export function ConversationScreen({
}
}, [messagesQuery]);
- const leaveConversation = useLeaveConversation(client);
+ const { openOptions, renaming, closeRename, saveRename } = useConversationOptionsSheet({
+ client,
+ conversationId,
+ sandboxId,
+ conversationTitle,
+ });
const { typingMembers, clearTypingForMember } = useMobileTypingState({
client,
currentUserId,
@@ -110,58 +116,14 @@ export function ConversationScreen({
router.push(chatInstancePickerPath(sandboxId));
}, [router, sandboxId]);
- const handleOpenConversationOptions = useCallback(() => {
- void Haptics.selectionAsync();
- showActionSheetWithOptions(
- {
- title: conversationTitle,
- options: ['Rename', 'Leave', 'Cancel'],
- cancelButtonIndex: 2,
- destructiveButtonIndex: 1,
- containerStyle: { paddingBottom: bottom },
- },
- index => {
- if (index === 0) {
- const params = new URLSearchParams({ conversationId, title: conversationRenameTitle });
- router.push(chatRenameConversationPath(sandboxId, params));
- return;
- }
- if (index === 1) {
- void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
- Alert.alert('Leave conversation?', 'This removes it from your list.', [
- { text: 'Cancel', style: 'cancel' },
- {
- text: 'Leave',
- style: 'destructive',
- onPress: () => {
- leaveConversation.mutate(
- { conversationId, sandboxId },
- {
- onSuccess: () => {
- router.replace(chatSandboxPath(sandboxId));
- },
- }
- );
- },
- },
- ]);
- }
- }
- );
- }, [
- bottom,
- conversationId,
- conversationRenameTitle,
- conversationTitle,
- leaveConversation,
- router,
- sandboxId,
- showActionSheetWithOptions,
- ]);
+ const handleOpenInstance = useCallback(() => {
+ router.push(`/(app)/kiloclaw/${sandboxId}/dashboard` as Href);
+ }, [router, sandboxId]);
+
useConversationPresence(sandboxId, conversationId);
useConversationEventSubscription(sandboxId, conversationId);
const handleActionFailed = useCallback(() => {
- toast.error("Couldn't reach the bot — please try again");
+ toast.error("Couldn't reach the bot. Please try again.");
}, []);
const handleMessageDeliveryFailed = useCallback(() => {
toast.error('Message could not be delivered to the bot');
@@ -215,8 +177,24 @@ export function ConversationScreen({
subtitle={instanceLabel}
canSwitchInstance={canSwitchInstance}
onSwitchInstance={handleSwitchInstance}
- onOpenOptions={handleOpenConversationOptions}
+ onOpenOptions={openOptions}
/>
+ {tokenError.hasError ? (
+ {
+ tokenError.retry();
+ }}
+ />
+ ) : null}
+ {messageHistoryState === 'stale-error' ? (
+ {
+ void messagesQuery.refetch();
+ }}
+ />
+ ) : null}
+ {renaming && (
+
+ )}
);
}
diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-conversation-message-actions.ts b/apps/mobile/src/components/kilo-chat/hooks/use-conversation-message-actions.ts
index 7959beba2a..14be4c0d7f 100644
--- a/apps/mobile/src/components/kilo-chat/hooks/use-conversation-message-actions.ts
+++ b/apps/mobile/src/components/kilo-chat/hooks/use-conversation-message-actions.ts
@@ -25,7 +25,7 @@ import { toast } from 'sonner-native';
import { executeActionWithMobileFeedback } from '../execute-action-feedback';
import { buildMessageActionSheetOptions, getSelectedMessageAction } from '../message-actions';
-import { canCopyMessage, canToggleReaction } from '../message-presentation';
+import { canCopyMessage, canRetryFailedMessage, canToggleReaction } from '../message-presentation';
type Params = {
client: KiloChatClient;
@@ -33,6 +33,7 @@ type Params = {
currentUserId: string | null;
onEditMessage: (message: Message) => void;
onReplyToMessage: (message: Message) => void;
+ onRetrySend: (message: Message) => void;
};
export function useConversationMessageActions({
@@ -41,6 +42,7 @@ export function useConversationMessageActions({
currentUserId,
onEditMessage,
onReplyToMessage,
+ onRetrySend,
}: Params) {
const { showActionSheetWithOptions } = useActionSheet();
const { bottom } = useSafeAreaInsets();
@@ -126,6 +128,7 @@ export function useConversationMessageActions({
canCopy: canCopyMessage(message),
canEdit: actionAvailability.canEdit,
canDelete: actionAvailability.canDelete,
+ canRetry: isOwnMessage && canRetryFailedMessage(message),
isPendingMessage,
});
showActionSheetWithOptions(
@@ -142,6 +145,10 @@ export function useConversationMessageActions({
return;
}
+ if (selectedAction.kind === 'retry') {
+ onRetrySend(message);
+ return;
+ }
if (selectedAction.kind === 'reaction') {
handleReactionPress(message, selectedAction.emoji);
return;
@@ -192,6 +199,7 @@ export function useConversationMessageActions({
handleReactionPress,
onEditMessage,
onReplyToMessage,
+ onRetrySend,
showActionSheetWithOptions,
]
);
diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-conversation-message-controller.ts b/apps/mobile/src/components/kilo-chat/hooks/use-conversation-message-controller.ts
index 3580cbd361..623c694fd4 100644
--- a/apps/mobile/src/components/kilo-chat/hooks/use-conversation-message-controller.ts
+++ b/apps/mobile/src/components/kilo-chat/hooks/use-conversation-message-controller.ts
@@ -1,5 +1,5 @@
import * as Haptics from 'expo-haptics';
-import { useEditMessage } from '@kilocode/kilo-chat-hooks';
+import { useEditMessage, useRedeliverMessage } from '@kilocode/kilo-chat-hooks';
import {
buildMessageEditContent,
contentBlocksToText,
@@ -60,6 +60,7 @@ export function useConversationMessageController({
const sendMutation = useSendMessage(client, conversationId, currentUserId);
const editMessage = useEditMessage(client, conversationId);
+ const redeliverMessage = useRedeliverMessage(client, conversationId);
const editingTextValue = useMemo(
() => (editingMessage ? editableText(editingMessage) : ''),
[editingMessage]
@@ -93,12 +94,33 @@ export function useConversationMessageController({
setRemovedEditAttachmentIds([]);
}, []);
+ const handleRetrySend = useCallback(
+ (message: Message) => {
+ // Redelivers the existing failed message row server-side — no new
+ // message, no attachment re-linking. The server clears delivery_failed
+ // and pushes message.redelivered to other clients.
+ redeliverMessage.mutate(
+ { messageId: message.id },
+ {
+ onSuccess: () => {
+ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ },
+ onError: err => {
+ toast.error(formatKiloChatError(err, 'Failed to retry send'));
+ },
+ }
+ );
+ },
+ [redeliverMessage]
+ );
+
const messageActions = useConversationMessageActions({
client,
conversationId,
currentUserId,
onEditMessage: startEditingMessage,
onReplyToMessage: startReplyToMessage,
+ onRetrySend: handleRetrySend,
});
const handleSend = useCallback(
diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-conversation-options-sheet.ts b/apps/mobile/src/components/kilo-chat/hooks/use-conversation-options-sheet.ts
new file mode 100644
index 0000000000..b081572cad
--- /dev/null
+++ b/apps/mobile/src/components/kilo-chat/hooks/use-conversation-options-sheet.ts
@@ -0,0 +1,87 @@
+import { useActionSheet } from '@expo/react-native-action-sheet';
+import { type KiloChatClient } from '@kilocode/kilo-chat';
+import * as Haptics from 'expo-haptics';
+import { useRouter } from 'expo-router';
+import { useCallback } from 'react';
+import { Alert } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { chatSandboxPath } from '@/lib/kilo-chat-routes';
+
+import { useConversationRename } from './use-conversation-rename';
+import { useLeaveConversation } from './use-conversations';
+
+// Backs the conversation header's "..." options sheet: rename (via
+// useConversationRename) and leave (with a native confirm + redirect).
+export function useConversationOptionsSheet({
+ client,
+ conversationId,
+ sandboxId,
+ conversationTitle,
+}: {
+ client: KiloChatClient;
+ conversationId: string;
+ sandboxId: string;
+ conversationTitle: string;
+}) {
+ const router = useRouter();
+ const { bottom } = useSafeAreaInsets();
+ const { showActionSheetWithOptions } = useActionSheet();
+ const leaveConversation = useLeaveConversation(client);
+ const rename = useConversationRename(client, conversationId, sandboxId);
+
+ const openOptions = useCallback(() => {
+ void Haptics.selectionAsync();
+ showActionSheetWithOptions(
+ {
+ title: conversationTitle,
+ options: ['Rename', 'Leave', 'Cancel'],
+ cancelButtonIndex: 2,
+ destructiveButtonIndex: 1,
+ containerStyle: { paddingBottom: bottom },
+ },
+ index => {
+ if (index === 0) {
+ rename.openRename();
+ return;
+ }
+ if (index === 1) {
+ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
+ Alert.alert('Leave conversation?', 'This removes it from your list.', [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Leave',
+ style: 'destructive',
+ onPress: () => {
+ leaveConversation.mutate(
+ { conversationId, sandboxId },
+ {
+ onSuccess: () => {
+ router.replace(chatSandboxPath(sandboxId));
+ },
+ }
+ );
+ },
+ },
+ ]);
+ }
+ }
+ );
+ }, [
+ bottom,
+ conversationId,
+ conversationTitle,
+ leaveConversation,
+ rename,
+ router,
+ sandboxId,
+ showActionSheetWithOptions,
+ ]);
+
+ return {
+ openOptions,
+ renaming: rename.renaming,
+ closeRename: rename.closeRename,
+ saveRename: rename.saveRename,
+ };
+}
diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-conversation-rename.ts b/apps/mobile/src/components/kilo-chat/hooks/use-conversation-rename.ts
new file mode 100644
index 0000000000..03119d2942
--- /dev/null
+++ b/apps/mobile/src/components/kilo-chat/hooks/use-conversation-rename.ts
@@ -0,0 +1,29 @@
+import { type KiloChatClient } from '@kilocode/kilo-chat';
+import { useCallback, useState } from 'react';
+
+import { useRenameConversation } from './use-conversations';
+
+// Backs the RenameModal for a conversation's options-sheet "Rename" action.
+export function useConversationRename(
+ client: KiloChatClient,
+ conversationId: string,
+ sandboxId: string
+) {
+ const renameConversation = useRenameConversation(client);
+ const [renaming, setRenaming] = useState(false);
+
+ const openRename = useCallback(() => {
+ setRenaming(true);
+ }, []);
+ const closeRename = useCallback(() => {
+ setRenaming(false);
+ }, []);
+ const saveRename = useCallback(
+ async (name: string) => {
+ await renameConversation.mutateAsync({ conversationId, title: name, sandboxId });
+ },
+ [renameConversation, conversationId, sandboxId]
+ );
+
+ return { renaming, openRename, closeRename, saveRename };
+}
diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts b/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts
index 915ddfe511..c6d16358ba 100644
--- a/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts
+++ b/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts
@@ -19,11 +19,9 @@ export function useCreateConversation(client: KiloChatClient) {
}
export function useRenameConversation(client: KiloChatClient) {
- return useSharedRenameConversation(client, {
- onError: err => {
- toast.error(formatKiloChatError(err, 'Failed to rename conversation'));
- },
- });
+ // No centralized toast here — callers rename via RenameModal, which stays
+ // open on failure and shows the error inline.
+ return useSharedRenameConversation(client);
}
export function useLeaveConversation(client: KiloChatClient) {
diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.test.ts b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.test.ts
index 4ea136b72c..5166dc5be1 100644
--- a/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.test.ts
+++ b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.test.ts
@@ -61,4 +61,53 @@ describe('useKiloChatTokenResponseGetter', () => {
unsubscribe();
});
+
+ it('caches the token when a PostgreSQL-format expiry is far in the future', async () => {
+ const response = {
+ token: 'kilo-jwt',
+ userId: 'user-2',
+ expiresAt: '2099-03-13 14:30:00+00',
+ };
+
+ mocks.getItemAsync.mockResolvedValue('auth-token-2');
+ mocks.getTokenQuery.mockResolvedValueOnce(response);
+
+ const { useKiloChatTokenResponseGetter } = await import('./use-kilo-chat-token');
+ const getTokenResponse = useKiloChatTokenResponseGetter();
+
+ await expect(getTokenResponse()).resolves.toBe(response);
+ await expect(getTokenResponse()).resolves.toBe(response);
+
+ // A second call within the cache window must not re-fetch. This only
+ // holds if the PG-format expiresAt was parsed into a valid future
+ // timestamp — with the old `new Date(pgTimestamp)` behavior it parses to
+ // an invalid Date (NaN), `expiresAtMs - Date.now() > 60_000` is false,
+ // and this would refetch, failing this assertion.
+ expect(mocks.getTokenQuery).toHaveBeenCalledTimes(1);
+ });
+
+ it('refetches when a PostgreSQL-format expiry is in the past', async () => {
+ const firstResponse = {
+ token: 'kilo-jwt-old',
+ userId: 'user-3',
+ expiresAt: '2000-01-01 00:00:00+00',
+ };
+ const secondResponse = {
+ token: 'kilo-jwt-new',
+ userId: 'user-3',
+ expiresAt: '2099-03-13 14:30:00+00',
+ };
+
+ mocks.getItemAsync.mockResolvedValue('auth-token-3');
+ mocks.getTokenQuery.mockResolvedValueOnce(firstResponse);
+ mocks.getTokenQuery.mockResolvedValueOnce(secondResponse);
+
+ const { useKiloChatTokenResponseGetter } = await import('./use-kilo-chat-token');
+ const getTokenResponse = useKiloChatTokenResponseGetter();
+
+ await expect(getTokenResponse()).resolves.toBe(firstResponse);
+ await expect(getTokenResponse()).resolves.toBe(secondResponse);
+
+ expect(mocks.getTokenQuery).toHaveBeenCalledTimes(2);
+ });
});
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
index 390196059e..9ef719005a 100644
--- 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
@@ -2,6 +2,7 @@ import * as SecureStore from 'expo-secure-store';
import { useCallback } from 'react';
import { AUTH_TOKEN_KEY } from '@/lib/storage-keys';
+import { parseTimestamp } from '@/lib/utils';
import { trpcClient } from '@/lib/trpc';
type KiloChatTokenResponse = Awaited>;
@@ -81,7 +82,7 @@ export function useKiloChatTokenResponseGetter(): () => Promise {
const response = await trpcClient.kiloChat.getToken.query();
- cache = { authToken, response, expiresAtMs: new Date(response.expiresAt).getTime() };
+ cache = { authToken, response, expiresAtMs: parseTimestamp(response.expiresAt).getTime() };
for (const listener of tokenResponseListeners) {
listener(response);
}
diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts
index 043054948e..aa74bbc8b7 100644
--- a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts
+++ b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
+import * as Sentry from '@sentry/react-native';
import * as Notifications from 'expo-notifications';
-import { toast } from 'sonner-native';
import { type KiloChatClient, type MarkConversationReadResponse } from '@kilocode/kilo-chat';
import { type BadgeCountRow } from '@kilocode/notifications';
@@ -36,8 +36,11 @@ export function useMarkRead(client: KiloChatClient) {
});
return result;
},
+ // Mark-read runs in the background (e.g. on scroll/focus) — a user-visible
+ // toast for a background failure is noise. Retry happens naturally on the
+ // next mark-read trigger; just log so we can see failure rates.
onError: error => {
- toast.error(error.message);
+ Sentry.captureException(error);
},
onMutate: () => ({ startBadgeFreshnessEpoch: advanceBadgeFreshnessEpoch() }),
onSuccess: (result, _variables, context) => {
diff --git a/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx b/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx
index 3335ab9d6d..1fd0d32b7b 100644
--- a/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx
+++ b/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx
@@ -1,4 +1,4 @@
-import { createContext, useEffect, useState } from 'react';
+import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { EventServiceClient } from '@kilocode/event-service';
import { KiloChatClient } from '@kilocode/kilo-chat';
@@ -19,10 +19,28 @@ type KiloChatProviderProps = {
export const KiloChatCurrentUserContext = createContext(null);
+type KiloChatTokenErrorState = {
+ hasError: boolean;
+ retry: () => void;
+};
+
+const KiloChatTokenErrorContext = createContext(undefined);
+
+/** Whether the initial kilo-chat token fetch failed, plus a way to retry it. */
+export function useKiloChatTokenError(): KiloChatTokenErrorState {
+ const context = useContext(KiloChatTokenErrorContext);
+ if (!context) {
+ throw new Error('useKiloChatTokenError must be used within a KiloChatProvider');
+ }
+ return context;
+}
+
export function KiloChatProvider({ children }: KiloChatProviderProps) {
const getToken = useKiloChatTokenGetter();
const getTokenResponse = useKiloChatTokenResponseGetter();
const [currentUserId, setCurrentUserId] = useState(null);
+ const [tokenError, setTokenError] = useState(false);
+ const [retryCount, setRetryCount] = useState(0);
const [value] = useState(() => {
const eventService = new EventServiceClient({
@@ -57,6 +75,7 @@ export function KiloChatProvider({ children }: KiloChatProviderProps) {
const unsubscribe = subscribeToKiloChatTokenResponses(response => {
if (!cancelled) {
setCurrentUserId(response.userId);
+ setTokenError(false);
}
});
@@ -65,10 +84,14 @@ export function KiloChatProvider({ children }: KiloChatProviderProps) {
const response = await getTokenResponse();
if (!cancelled) {
setCurrentUserId(response.userId);
+ setTokenError(false);
}
} catch {
- // Keep the provider in its loading state. A later successful token fetch
- // from any Kilo Chat caller will notify the subscription above.
+ // Surface the failure instead of swallowing it — the composer would
+ // otherwise stay stuck behind "Loading user..." with no way out.
+ if (!cancelled) {
+ setTokenError(true);
+ }
}
}
@@ -78,15 +101,26 @@ export function KiloChatProvider({ children }: KiloChatProviderProps) {
cancelled = true;
unsubscribe();
};
- }, [getTokenResponse]);
+ }, [getTokenResponse, retryCount]);
+
+ const retryTokenFetch = useCallback(() => {
+ setTokenError(false);
+ setRetryCount(count => count + 1);
+ }, []);
+ const tokenErrorValue = useMemo(
+ () => ({ hasError: tokenError, retry: retryTokenFetch }),
+ [tokenError, retryTokenFetch]
+ );
return (
-
- {children}
-
+
+
+ {children}
+
+
);
}
diff --git a/apps/mobile/src/components/kilo-chat/message-actions.test.ts b/apps/mobile/src/components/kilo-chat/message-actions.test.ts
index dfb811f2fd..52c0bf3aac 100644
--- a/apps/mobile/src/components/kilo-chat/message-actions.test.ts
+++ b/apps/mobile/src/components/kilo-chat/message-actions.test.ts
@@ -132,6 +132,21 @@ describe('buildMessageActionSheetOptions', () => {
expect(actionSheet.destructiveButtonIndex).toBeUndefined();
});
+ it('offers retry send first for delivery-failed own messages', () => {
+ const actionSheet = buildMessageActionSheetOptions({
+ canReact: false,
+ canReply: false,
+ canCopy: true,
+ canEdit: false,
+ canDelete: true,
+ canRetry: true,
+ });
+
+ expect(actionSheet.options).toEqual(['Retry send', 'Copy', 'Delete', 'Cancel']);
+ expect(actionSheet.actions[0]).toEqual({ kind: 'retry', label: 'Retry send' });
+ expect(actionSheet.destructiveButtonIndex).toBe(2);
+ });
+
it('orders reactions, reply, copy, edit, delete, then cancel', () => {
const actionSheet = buildMessageActionSheetOptions({
canReact: true,
diff --git a/apps/mobile/src/components/kilo-chat/message-actions.ts b/apps/mobile/src/components/kilo-chat/message-actions.ts
index 7c5b0ae6b1..c90afabb8f 100644
--- a/apps/mobile/src/components/kilo-chat/message-actions.ts
+++ b/apps/mobile/src/components/kilo-chat/message-actions.ts
@@ -3,6 +3,7 @@ const FIRST_REACTION_EMOJIS = ['👍', '❤️', '😂', '🎉'] as const;
type ReactionEmoji = (typeof FIRST_REACTION_EMOJIS)[number];
type MessageAction =
+ | { kind: 'retry'; label: 'Retry send' }
| { kind: 'reaction'; label: string; emoji: ReactionEmoji }
| { kind: 'more-reactions'; label: 'More reactions' }
| { kind: 'reply'; label: 'Reply' }
@@ -17,6 +18,7 @@ type BuildMessageActionSheetOptionsInput = {
canCopy: boolean;
canEdit: boolean;
canDelete: boolean;
+ canRetry?: boolean;
isPendingMessage?: boolean;
};
@@ -26,6 +28,7 @@ export function buildMessageActionSheetOptions({
canCopy,
canEdit,
canDelete,
+ canRetry = false,
isPendingMessage = false,
}: BuildMessageActionSheetOptionsInput): {
actions: MessageAction[];
@@ -35,6 +38,9 @@ export function buildMessageActionSheetOptions({
} {
const actions: MessageAction[] = [];
const canUseApiBackedActions = !isPendingMessage;
+ if (canRetry) {
+ actions.push({ kind: 'retry', label: 'Retry send' });
+ }
if (canUseApiBackedActions && canReact) {
for (const emoji of FIRST_REACTION_EMOJIS) {
actions.push({ kind: 'reaction', label: emoji, emoji });
diff --git a/apps/mobile/src/components/kilo-chat/message-attachment.tsx b/apps/mobile/src/components/kilo-chat/message-attachment.tsx
index 1a2e661094..54f46a6893 100644
--- a/apps/mobile/src/components/kilo-chat/message-attachment.tsx
+++ b/apps/mobile/src/components/kilo-chat/message-attachment.tsx
@@ -30,6 +30,7 @@ export function MessageAttachment({ client, conversationId, block, isFromMe }: P
const isImage = isImageMimeType(block.mimeType);
const [previewUrl, setPreviewUrl] = useState(null);
const [sharing, setSharing] = useState(false);
+ const [previewShareError, setPreviewShareError] = useState(null);
const urlQuery = useAttachmentUrl(client, conversationId, block.attachmentId, {
enabled: isImage,
});
@@ -41,6 +42,7 @@ export function MessageAttachment({ client, conversationId, block, isFromMe }: P
async function handleShare() {
setSharing(true);
+ setPreviewShareError(null);
try {
const result = await urlQuery.refetch();
const url = result.data?.url;
@@ -54,7 +56,13 @@ export function MessageAttachment({ client, conversationId, block, isFromMe }: P
filename: block.filename,
});
} catch {
- toast.error(getAttachmentOpenErrorMessage());
+ // The image preview modal covers the toast layer, so a failure there
+ // renders inline in the modal instead of a toast the user can't see.
+ if (previewUrl !== null) {
+ setPreviewShareError(getAttachmentOpenErrorMessage());
+ } else {
+ toast.error(getAttachmentOpenErrorMessage());
+ }
} finally {
setSharing(false);
}
@@ -119,8 +127,10 @@ export function MessageAttachment({ client, conversationId, block, isFromMe }: P
uri={previewUrl}
filename={block.filename}
sharing={sharing}
+ shareError={previewShareError}
onClose={() => {
setPreviewUrl(null);
+ setPreviewShareError(null);
}}
onShare={() => {
void handleShare();
@@ -136,6 +146,7 @@ export function MessageAttachment({ client, conversationId, block, isFromMe }: P
void handleShare();
}}
disabled={sharing}
+ accessibilityState={{ busy: sharing }}
className={cn(
'mt-2 max-w-56 flex-row items-center gap-2 rounded-md border px-3 py-2 active:opacity-80 disabled:opacity-60',
isFromMe ? 'border-primary-foreground' : 'border-border bg-secondary'
diff --git a/apps/mobile/src/components/kilo-chat/message-bubble-content.tsx b/apps/mobile/src/components/kilo-chat/message-bubble-content.tsx
index 5fe9e63e2e..fe5b28b462 100644
--- a/apps/mobile/src/components/kilo-chat/message-bubble-content.tsx
+++ b/apps/mobile/src/components/kilo-chat/message-bubble-content.tsx
@@ -126,9 +126,7 @@ export function MessageBubbleContent({
{deliveryFailureLabel && (
-
- {deliveryFailureLabel}
-
+ {deliveryFailureLabel}
)}
>
diff --git a/apps/mobile/src/components/kilo-chat/message-bubble.tsx b/apps/mobile/src/components/kilo-chat/message-bubble.tsx
index 3049232086..63644b52f4 100644
--- a/apps/mobile/src/components/kilo-chat/message-bubble.tsx
+++ b/apps/mobile/src/components/kilo-chat/message-bubble.tsx
@@ -176,6 +176,7 @@ function MessageBubbleComponent({
{timestamp !== null && (
{formatTimestamp(timestamp)}
+ {edited ? ' (edited)' : ''}
)}
diff --git a/apps/mobile/src/components/kilo-chat/message-history-state.test.ts b/apps/mobile/src/components/kilo-chat/message-history-state.test.ts
index 10247f2a70..52f9cf27cf 100644
--- a/apps/mobile/src/components/kilo-chat/message-history-state.test.ts
+++ b/apps/mobile/src/components/kilo-chat/message-history-state.test.ts
@@ -21,6 +21,12 @@ describe('getMessageHistoryContentState', () => {
'ready'
);
});
+
+ it('prefers cached data over a refetch error', () => {
+ expect(getMessageHistoryContentState({ isPending: false, isError: true, hasData: true })).toBe(
+ 'stale-error'
+ );
+ });
});
describe('shouldMarkLatestMessageRead', () => {
diff --git a/apps/mobile/src/components/kilo-chat/message-history-state.ts b/apps/mobile/src/components/kilo-chat/message-history-state.ts
index 2f7d5c7d19..9dcdaf97d5 100644
--- a/apps/mobile/src/components/kilo-chat/message-history-state.ts
+++ b/apps/mobile/src/components/kilo-chat/message-history-state.ts
@@ -1,4 +1,4 @@
-type MessageHistoryContentState = 'loading' | 'error' | 'ready';
+type MessageHistoryContentState = 'loading' | 'error' | 'ready' | 'stale-error';
export function getMessageHistoryContentState({
isPending,
@@ -12,13 +12,15 @@ export function getMessageHistoryContentState({
if (isPending) {
return 'loading';
}
+ // Cached data wins: a refetch failure with existing messages is a stale-error
+ // (small inline indicator), never a full-screen error that hides history.
+ if (hasData) {
+ return isError ? 'stale-error' : 'ready';
+ }
if (isError) {
return 'error';
}
- if (!hasData) {
- return 'loading';
- }
- return 'ready';
+ return 'loading';
}
export function shouldMarkLatestMessageRead({
diff --git a/apps/mobile/src/components/kilo-chat/message-image-preview-modal.tsx b/apps/mobile/src/components/kilo-chat/message-image-preview-modal.tsx
index ed7d7ba7b7..54345bfd58 100644
--- a/apps/mobile/src/components/kilo-chat/message-image-preview-modal.tsx
+++ b/apps/mobile/src/components/kilo-chat/message-image-preview-modal.tsx
@@ -3,6 +3,7 @@ import { Modal, Pressable, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Image } from '@/components/ui/image';
+import { Text } from '@/components/ui/text';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
type Props = {
@@ -10,6 +11,8 @@ type Props = {
uri: string | null;
filename: string;
sharing?: boolean;
+ /** Share failure message. Rendered inline — the toast layer sits behind this modal. */
+ shareError?: string | null;
onClose: () => void;
onShare: () => void;
};
@@ -19,6 +22,7 @@ export function MessageImagePreviewModal({
uri,
filename,
sharing = false,
+ shareError = null,
onClose,
onShare,
}: Props) {
@@ -43,6 +47,7 @@ export function MessageImagePreviewModal({
{uri ? : null}
+ {shareError ? (
+
+
+
+ {shareError}
+
+
+
+ ) : null}
);
diff --git a/apps/mobile/src/components/kilo-chat/message-input-content.tsx b/apps/mobile/src/components/kilo-chat/message-input-content.tsx
index 60957afa4e..7a5274ac8a 100644
--- a/apps/mobile/src/components/kilo-chat/message-input-content.tsx
+++ b/apps/mobile/src/components/kilo-chat/message-input-content.tsx
@@ -65,6 +65,8 @@ export function MessageInputContent({
replyingTo,
onCancelReply,
disabledReason,
+ showInstanceCta,
+ onOpenInstance,
clearOnSubmit,
botName,
typingMembers = new Map(),
@@ -256,6 +258,8 @@ export function MessageInputContent({
controlsDisabled={controlsDisabled}
disabled={disabled}
disabledReason={disabledReason}
+ showInstanceCta={showInstanceCta}
+ onOpenInstance={onOpenInstance}
draftLength={draftLength}
editableAttachmentRows={editableAttachmentRows}
inputHeight={inputMeasure.height}
diff --git a/apps/mobile/src/components/kilo-chat/message-input-layout.test.ts b/apps/mobile/src/components/kilo-chat/message-input-layout.test.ts
index e713f687d5..4430e44044 100644
--- a/apps/mobile/src/components/kilo-chat/message-input-layout.test.ts
+++ b/apps/mobile/src/components/kilo-chat/message-input-layout.test.ts
@@ -29,7 +29,7 @@ describe('message input layout', () => {
});
});
- it('keeps composer bottom padding constant across safe-area insets', () => {
+ it('defaults composer bottom padding to the minimum clearance when no safe-area inset is given', () => {
expect(resolveMessageInputBottomPadding()).toBe(8);
});
@@ -42,12 +42,21 @@ describe('message input layout', () => {
).toBe(32);
});
- it('keeps iOS composer bottom padding controlled by the existing keyboard wrapper', () => {
+ it('respects the iOS bottom safe-area inset so the composer clears the home indicator', () => {
expect(
resolveMessageInputBottomPadding({
bottomSafeAreaInset: 24,
platform: 'ios',
})
+ ).toBe(24);
+ });
+
+ it('floors iOS composer bottom padding at the minimum clearance when the safe-area inset is small', () => {
+ expect(
+ resolveMessageInputBottomPadding({
+ bottomSafeAreaInset: 4,
+ platform: 'ios',
+ })
).toBe(8);
});
diff --git a/apps/mobile/src/components/kilo-chat/message-input-layout.ts b/apps/mobile/src/components/kilo-chat/message-input-layout.ts
index f06576106a..13f26f96ba 100644
--- a/apps/mobile/src/components/kilo-chat/message-input-layout.ts
+++ b/apps/mobile/src/components/kilo-chat/message-input-layout.ts
@@ -53,5 +53,5 @@ export function resolveMessageInputBottomPadding({
return MESSAGE_INPUT_BOTTOM_CLEARANCE + Math.max(bottomSafeAreaInset, 0);
}
- return MESSAGE_INPUT_BOTTOM_CLEARANCE;
+ return Math.max(bottomSafeAreaInset, MESSAGE_INPUT_BOTTOM_CLEARANCE);
}
diff --git a/apps/mobile/src/components/kilo-chat/message-input-types.ts b/apps/mobile/src/components/kilo-chat/message-input-types.ts
index 367b32d5f4..254ad2a0c9 100644
--- a/apps/mobile/src/components/kilo-chat/message-input-types.ts
+++ b/apps/mobile/src/components/kilo-chat/message-input-types.ts
@@ -44,6 +44,8 @@ export type CommonProps = {
replyingTo?: Message | null;
onCancelReply?: () => void;
disabledReason?: string | null;
+ showInstanceCta?: boolean;
+ onOpenInstance?: () => void;
clearOnSubmit?: boolean;
botName?: string | null;
typingMembers?: Map;
diff --git a/apps/mobile/src/components/kilo-chat/message-input-view.tsx b/apps/mobile/src/components/kilo-chat/message-input-view.tsx
index 964803e01a..3fc4335c8d 100644
--- a/apps/mobile/src/components/kilo-chat/message-input-view.tsx
+++ b/apps/mobile/src/components/kilo-chat/message-input-view.tsx
@@ -19,6 +19,8 @@ type Props = {
controlsDisabled: boolean;
disabled?: boolean;
disabledReason?: string | null;
+ showInstanceCta?: boolean;
+ onOpenInstance?: () => void;
draftLength: number;
editableAttachmentRows: QueuedAttachment[];
inputHeight: number;
@@ -51,6 +53,8 @@ export function MessageInputView({
controlsDisabled,
disabled,
disabledReason,
+ showInstanceCta,
+ onOpenInstance,
draftLength,
editableAttachmentRows,
inputHeight,
@@ -104,8 +108,19 @@ export function MessageInputView({
)}
{disabledReason && (
-
- {disabledReason}
+
+ {disabledReason}
+ {showInstanceCta && onOpenInstance ? (
+
+ Open instance
+
+ ) : null}
)}
{attachmentQueue && (
@@ -146,7 +161,8 @@ export function MessageInputView({
ref={inputRef}
className={cn(
'rounded-md border bg-card px-3 text-foreground',
- overLimit ? 'border-destructive' : 'border-input'
+ overLimit ? 'border-destructive' : 'border-input',
+ disabled && 'opacity-50'
)}
style={[messageInputTextStyle, { height: inputHeight }]}
placeholder="Message"
@@ -155,6 +171,7 @@ export function MessageInputView({
multiline
scrollEnabled={shouldScroll}
editable={!disabled}
+ accessibilityState={{ disabled }}
onChangeText={onChangeText}
onFocus={onInputFocus}
onBlur={onInputBlur}
diff --git a/apps/mobile/src/components/kilo-chat/message-presentation.test.ts b/apps/mobile/src/components/kilo-chat/message-presentation.test.ts
index 9600e4b26d..4c12bb8234 100644
--- a/apps/mobile/src/components/kilo-chat/message-presentation.test.ts
+++ b/apps/mobile/src/components/kilo-chat/message-presentation.test.ts
@@ -1,9 +1,13 @@
+/* eslint-disable max-lines -- one cohesive test suite per pure-logic module; an
+ artificial split across files would scatter closely related coverage for
+ message-presentation.ts's exports. */
import { describe, expect, it, vi } from 'vitest';
import { createMessageRequestSchema, type Message } from '@kilocode/kilo-chat';
import {
buildSendMessageVariables,
canCopyMessage,
+ canRetryFailedMessage,
canShowReactionPills,
canToggleReaction,
createSendMessageClientId,
@@ -235,6 +239,20 @@ describe('isMessageEdited', () => {
});
});
+describe('canRetryFailedMessage', () => {
+ it('allows retrying delivery-failed messages', () => {
+ expect(canRetryFailedMessage(message({ deliveryFailed: true }))).toBe(true);
+ });
+
+ it('blocks retry for delivered messages', () => {
+ expect(canRetryFailedMessage(message({ deliveryFailed: false }))).toBe(false);
+ });
+
+ it('blocks retry for deleted messages', () => {
+ expect(canRetryFailedMessage(message({ deliveryFailed: true, deleted: true }))).toBe(false);
+ });
+});
+
describe('canShowReactionPills', () => {
it('hides reactions for deleted messages', () => {
expect(
diff --git a/apps/mobile/src/components/kilo-chat/message-presentation.ts b/apps/mobile/src/components/kilo-chat/message-presentation.ts
index 1ad2ef617e..c47c2550f6 100644
--- a/apps/mobile/src/components/kilo-chat/message-presentation.ts
+++ b/apps/mobile/src/components/kilo-chat/message-presentation.ts
@@ -92,6 +92,10 @@ export function isMessageEdited(message: Message): boolean {
return !message.deleted && message.clientUpdatedAt !== null;
}
+export function canRetryFailedMessage(message: Message): boolean {
+ return !message.deleted && message.deliveryFailed;
+}
+
function firstDisplayValue(values: readonly (string | null | undefined)[]): string | null {
for (const value of values) {
const trimmed = value?.trim();
diff --git a/apps/mobile/src/components/kilo-chat/message-reaction-picker-sheet.tsx b/apps/mobile/src/components/kilo-chat/message-reaction-picker-sheet.tsx
index a411e61076..15be145383 100644
--- a/apps/mobile/src/components/kilo-chat/message-reaction-picker-sheet.tsx
+++ b/apps/mobile/src/components/kilo-chat/message-reaction-picker-sheet.tsx
@@ -1,6 +1,9 @@
import { Portal } from '@rn-primitives/portal';
import { X } from 'lucide-react-native';
-import { Pressable, View } from 'react-native';
+import { useEffect } from 'react';
+import { BackHandler, Pressable, View } from 'react-native';
+import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown } from 'react-native-reanimated';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text } from '@/components/ui/text';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
@@ -33,6 +36,21 @@ export function MessageReactionPickerSheet({
onSelect,
}: Readonly) {
const colors = useThemeColors();
+ const insets = useSafeAreaInsets();
+
+ useEffect(() => {
+ if (!visible) {
+ return undefined;
+ }
+ const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
+ onClose();
+ return true;
+ });
+ return () => {
+ subscription.remove();
+ };
+ }, [visible, onClose]);
+
if (!visible) {
return null;
}
@@ -41,9 +59,19 @@ export function MessageReactionPickerSheet({
return (
-
+
-
+
Reactions
) : null}
-
-
+
+
);
}
diff --git a/apps/mobile/src/components/kilo-chat/message-reaction-pills.tsx b/apps/mobile/src/components/kilo-chat/message-reaction-pills.tsx
index adee4596e7..5e01ecc32e 100644
--- a/apps/mobile/src/components/kilo-chat/message-reaction-pills.tsx
+++ b/apps/mobile/src/components/kilo-chat/message-reaction-pills.tsx
@@ -37,8 +37,11 @@ export function MessageReactionPills({
onPress={() => {
onReactionPress(message, reaction.emoji);
}}
+ accessibilityRole="button"
+ accessibilityLabel={`${reaction.emoji} reaction, ${reaction.count}`}
+ accessibilityState={{ selected: hasReacted }}
className={cn(
- 'min-h-11 flex-row items-center gap-1 rounded-full px-3 py-1',
+ 'min-h-11 flex-row items-center gap-1 rounded-full px-3 py-1 active:opacity-70',
hasReacted ? 'bg-primary' : 'bg-neutral-200 dark:bg-neutral-700'
)}
>
diff --git a/apps/mobile/src/components/kilo-chat/rename-conversation-sheet.tsx b/apps/mobile/src/components/kilo-chat/rename-conversation-sheet.tsx
deleted file mode 100644
index 6f33437c26..0000000000
--- a/apps/mobile/src/components/kilo-chat/rename-conversation-sheet.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-import { CONVERSATION_TITLE_MAX_CHARS } from '@kilocode/kilo-chat';
-import { useRef, useState } from 'react';
-import { Pressable, TextInput, View } from 'react-native';
-
-import { Button } from '@/components/ui/button';
-import { Text } from '@/components/ui/text';
-import { useThemeColors } from '@/lib/hooks/use-theme-colors';
-
-type RenameConversationSheetProps = {
- initialTitle: string;
- isSaving: boolean;
- onCancel: () => void;
- onSave: (title: string) => void;
-};
-
-function canSaveTitle(text: string, initialTitle: string): boolean {
- const trimmed = text.trim();
- return (
- trimmed.length > 0 &&
- trimmed.length <= CONVERSATION_TITLE_MAX_CHARS &&
- trimmed !== initialTitle.trim()
- );
-}
-
-export function RenameConversationSheet({
- initialTitle,
- isSaving,
- onCancel,
- onSave,
-}: Readonly) {
- const colors = useThemeColors();
- const titleRef = useRef(initialTitle);
- const [canSave, setCanSave] = useState(false);
-
- function handleTextChange(text: string) {
- titleRef.current = text;
- setCanSave(canSaveTitle(text, initialTitle));
- }
-
- function handleSave() {
- const title = titleRef.current.trim();
- if (canSaveTitle(title, initialTitle)) {
- onSave(title);
- }
- }
-
- return (
-
-
-
- Rename conversation
- Set a short name for this thread.
-
-
-
-
- Cancel
-
-
- {isSaving ? 'Saving…' : 'Save'}
-
-
-
-
- );
-}
diff --git a/apps/mobile/src/components/kilo-pass/kilo-pass-subscription-screen.tsx b/apps/mobile/src/components/kilo-pass/kilo-pass-subscription-screen.tsx
index 0f5792683c..b9f5eb3725 100644
--- a/apps/mobile/src/components/kilo-pass/kilo-pass-subscription-screen.tsx
+++ b/apps/mobile/src/components/kilo-pass/kilo-pass-subscription-screen.tsx
@@ -1,6 +1,6 @@
import * as Haptics from 'expo-haptics';
-import * as WebBrowser from 'expo-web-browser';
import { useRouter } from 'expo-router';
+import { useEffect, useState } from 'react';
import { ActivityIndicator, Pressable, ScrollView, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
import { WEB_BASE_URL } from '@/lib/config';
+import { openExternalUrl } from '@/lib/external-link';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { getKiloPassLegalLinks, KILO_PASS_LEGAL_DISCLOSURE } from '@/lib/kilo-pass/legal-links';
import { ensureProfileAfterKiloPassPurchase } from '@/lib/kilo-pass/navigation';
@@ -18,9 +19,16 @@ import {
} from '@/lib/kilo-pass/subscription-page-copy';
import { type AppStoreKiloPassProduct } from '@/lib/kilo-pass/store-products';
import { useStoreKiloPassProducts } from '@/lib/kilo-pass/use-store-kilo-pass-products';
-import { useStoreKiloPassPurchase } from '@/lib/kilo-pass/use-store-kilo-pass-purchase';
+import {
+ useInlinePurchaseErrorOwnership,
+ useStoreKiloPassPurchase,
+} from '@/lib/kilo-pass/use-store-kilo-pass-purchase';
+import { useDetailScreenBottomPadding } from '@/lib/screen-insets';
+import { cn } from '@/lib/utils';
import { RestorePurchasesButton } from './restore-purchases-button';
+type SubscriptionScreenFeedback = { type: 'success' | 'info' | 'error'; text: string };
+
function formatTier(product: AppStoreKiloPassProduct): string {
return `$${product.webMonthlyPriceUsd} credits`;
}
@@ -32,11 +40,24 @@ function formatStorePrice(product: AppStoreKiloPassProduct): string {
export function KiloPassSubscriptionScreen() {
const colors = useThemeColors();
const insets = useSafeAreaInsets();
+ const paddingBottom = useDetailScreenBottomPadding();
const router = useRouter();
const productsQuery = useStoreKiloPassProducts();
const purchase = useStoreKiloPassPurchase();
+ useInlinePurchaseErrorOwnership();
+ const [restoreFeedback, setRestoreFeedback] = useState(null);
+ const feedback: SubscriptionScreenFeedback | null = purchase.errorMessage
+ ? { type: 'error', text: purchase.errorMessage }
+ : restoreFeedback;
const isRetryDisabled = purchase.isPending || productsQuery.isRefetching;
const [privacyPolicyLink, termsOfUseLink] = getKiloPassLegalLinks(WEB_BASE_URL);
+ const { clearError } = purchase;
+ useEffect(
+ () => () => {
+ clearError();
+ },
+ [clearError]
+ );
const handleProductPress = (product: AppStoreKiloPassProduct) => {
void Haptics.selectionAsync();
void purchase.purchase(product, {
@@ -52,13 +73,26 @@ export function KiloPassSubscriptionScreen() {
{KILO_PASS_SUBSCRIPTION_HEADER_DESCRIPTION}
+ {feedback && (
+
+ {feedback.text}
+
+ )}
+
{productsQuery.isLoading &&
[0, 1, 2].map(index => (
@@ -96,7 +130,10 @@ export function KiloPassSubscriptionScreen() {
accessibilityLabel={`${formatTier(product)}, ${formatStorePrice(product)}`}
accessibilityRole="button"
accessibilityState={{ busy: purchase.isPending, disabled: purchase.isPending }}
- className="rounded-xl border border-border bg-card p-5 active:opacity-80"
+ className={cn(
+ 'rounded-xl border border-border bg-card p-5 active:opacity-80',
+ purchase.isPending && 'opacity-50'
+ )}
disabled={purchase.isPending}
onPress={() => {
handleProductPress(product);
@@ -116,16 +153,26 @@ export function KiloPassSubscriptionScreen() {
))}
-
+ {
+ if (result === 'restored') {
+ setRestoreFeedback({ type: 'success', text: 'Subscription restored.' });
+ } else if (result === 'empty') {
+ setRestoreFeedback({ type: 'info', text: 'No purchases to restore.' });
+ } else {
+ setRestoreFeedback(null);
+ }
+ }}
+ />
{KILO_PASS_LEGAL_DISCLOSURE}
{' By subscribing, you agree to the '}
{
- void WebBrowser.openBrowserAsync(termsOfUseLink.url);
+ void openExternalUrl(termsOfUseLink.url, { label: termsOfUseLink.label });
}}
>
{termsOfUseLink.label}
@@ -133,9 +180,9 @@ export function KiloPassSubscriptionScreen() {
{' and acknowledge the '}
{
- void WebBrowser.openBrowserAsync(privacyPolicyLink.url);
+ void openExternalUrl(privacyPolicyLink.url, { label: privacyPolicyLink.label });
}}
>
{privacyPolicyLink.label}
diff --git a/apps/mobile/src/components/kilo-pass/restore-purchases-button.tsx b/apps/mobile/src/components/kilo-pass/restore-purchases-button.tsx
index a20d30a966..3778ba558c 100644
--- a/apps/mobile/src/components/kilo-pass/restore-purchases-button.tsx
+++ b/apps/mobile/src/components/kilo-pass/restore-purchases-button.tsx
@@ -5,9 +5,17 @@ import { toast } from 'sonner-native';
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
-import { useStoreKiloPassPurchase } from '@/lib/kilo-pass/use-store-kilo-pass-purchase';
+import {
+ type StoreKiloPassRestorePurchasesResult,
+ useStoreKiloPassPurchase,
+} from '@/lib/kilo-pass/use-store-kilo-pass-purchase';
-export function RestorePurchasesButton() {
+type RestorePurchasesButtonProps = {
+ /** Called with the outcome instead of the default toast — for callers that render feedback inline. */
+ onResult?: (result: StoreKiloPassRestorePurchasesResult) => void;
+};
+
+export function RestorePurchasesButton({ onResult }: Readonly = {}) {
const colors = useThemeColors();
const { isPending, isRestoringPurchases, restorePurchases } = useStoreKiloPassPurchase();
@@ -21,6 +29,10 @@ export function RestorePurchasesButton() {
void Haptics.selectionAsync();
void (async () => {
const result = await restorePurchases();
+ if (onResult) {
+ onResult(result);
+ return;
+ }
if (result === 'restored') {
toast.success('Subscription restored.');
}
diff --git a/apps/mobile/src/components/kiloclaw/access-required-screen.tsx b/apps/mobile/src/components/kiloclaw/access-required-screen.tsx
index e9a3a64832..0bbeeb18fe 100644
--- a/apps/mobile/src/components/kiloclaw/access-required-screen.tsx
+++ b/apps/mobile/src/components/kiloclaw/access-required-screen.tsx
@@ -18,8 +18,8 @@ import {
type AccessRequiredSubcase,
} from '@/lib/analytics/onboarding-events';
import { trackEvent } from '@/lib/appsflyer';
-import { WEB_BASE_URL } from '@/lib/config';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { resolveAccessIssueUrl } from '@/lib/kiloclaw/access-issue';
import { cn } from '@/lib/utils';
export type { AccessRequiredSubcase };
@@ -86,12 +86,6 @@ const SUBCASE_CONTENT: Record = {
},
};
-const SUBSCRIBE_SUBCASES: ReadonlySet = new Set([
- 'trial_expired',
- 'subscription_canceled',
- 'subscription_past_due',
-]);
-
type AccessRequiredScreenProps = {
subcase: AccessRequiredSubcase;
};
@@ -115,8 +109,7 @@ export function AccessRequiredScreen({ subcase }: Readonly {
- const target = SUBSCRIBE_SUBCASES.has(subcase) ? `${WEB_BASE_URL}/claw` : WEB_BASE_URL;
- void Linking.openURL(target);
+ void Linking.openURL(resolveAccessIssueUrl(subcase));
};
if (Platform.OS === 'ios') {
@@ -139,6 +132,9 @@ export function AccessRequiredScreen({ subcase }: Readonly
KiloClaw access is managed outside the iOS app for this account.
+
+ Questions? Contact hi@kilo.ai.
+
);
diff --git a/apps/mobile/src/components/kiloclaw/billing-banner.tsx b/apps/mobile/src/components/kiloclaw/billing-banner.tsx
index bf1a09d0cd..6ed8c15817 100644
--- a/apps/mobile/src/components/kiloclaw/billing-banner.tsx
+++ b/apps/mobile/src/components/kiloclaw/billing-banner.tsx
@@ -101,7 +101,7 @@ function getBannerConfig(billing: ClawBillingStatus, state: string): BannerConfi
case 'subscription_past_due': {
return {
icon: AlertTriangle,
- message: 'Payment past due — please update your payment method',
+ message: 'Payment past due. Please update your payment method.',
severity: 'danger',
};
}
diff --git a/apps/mobile/src/components/kiloclaw/changelog-list.tsx b/apps/mobile/src/components/kiloclaw/changelog-list.tsx
index 3c42bc1200..461e769900 100644
--- a/apps/mobile/src/components/kiloclaw/changelog-list.tsx
+++ b/apps/mobile/src/components/kiloclaw/changelog-list.tsx
@@ -1,8 +1,11 @@
-import { Bug, Sparkles } from 'lucide-react-native';
+import { Bug, RefreshCw, Sparkles } from 'lucide-react-native';
import { View } from 'react-native';
+import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
+import { captureEvent, INSTANCE_ACTION_EVENT } from '@/lib/analytics/posthog';
import { type useKiloClawChangelog } from '@/lib/hooks/use-kiloclaw-queries';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { cn } from '@/lib/utils';
type ChangelogEntry = NonNullable['data']>[number];
@@ -10,22 +13,34 @@ type ChangelogEntry = NonNullable['data'
const DEPLOY_HINTS: Record = {
redeploy_suggested: {
label: 'Redeploy suggested',
- bgClass: 'bg-blue-100 dark:bg-blue-950',
- textClass: 'text-blue-700 dark:text-blue-300',
+ bgClass: 'bg-info-tile-bg',
+ textClass: 'text-info',
},
redeploy_required: {
label: 'Redeploy required',
- bgClass: 'bg-amber-100 dark:bg-amber-950',
- textClass: 'text-amber-700 dark:text-amber-300',
+ bgClass: 'bg-warn-tile-bg',
+ textClass: 'text-warn',
},
upgrade_required: {
label: 'Upgrade required',
- bgClass: 'bg-red-100 dark:bg-red-950',
- textClass: 'text-red-700 dark:text-red-300',
+ bgClass: 'bg-danger-tile-bg',
+ textClass: 'text-destructive',
},
};
-export function ChangelogList({ entries }: Readonly<{ entries: ChangelogEntry[] }>) {
+export function ChangelogList({
+ entries,
+ isRedeploying,
+ onRedeploy,
+ onUpgrade,
+}: Readonly<{
+ entries: ChangelogEntry[];
+ isRedeploying: boolean;
+ onRedeploy: () => void;
+ onUpgrade: () => void;
+}>) {
+ const colors = useThemeColors();
+
return (
{entries.map((entry, index) => {
@@ -48,6 +63,26 @@ export function ChangelogList({ entries }: Readonly<{ entries: ChangelogEntry[]
)}
{entry.description}
+ {entry.deployHint === 'redeploy_required' && (
+ {
+ captureEvent(INSTANCE_ACTION_EVENT, { surface: 'claw', action: 'redeploy' });
+ onRedeploy();
+ }}
+ className="flex-row gap-1.5 self-start"
+ >
+ {!isRedeploying && }
+ Redeploy
+
+ )}
+ {entry.deployHint === 'upgrade_required' && (
+
+ Manage version
+
+ )}
);
})}
diff --git a/apps/mobile/src/components/kiloclaw/dashboard-parts.tsx b/apps/mobile/src/components/kiloclaw/dashboard-parts.tsx
index f7536c1e91..49401507bd 100644
--- a/apps/mobile/src/components/kiloclaw/dashboard-parts.tsx
+++ b/apps/mobile/src/components/kiloclaw/dashboard-parts.tsx
@@ -1,7 +1,9 @@
-import { AlertTriangle, Bot, Trash2 } from 'lucide-react-native';
+import { AlertTriangle, Bot, RefreshCw, Trash2 } from 'lucide-react-native';
import { ActivityIndicator, Pressable, View } from 'react-native';
import { statusLabel, statusTone } from '@/components/kiloclaw/status-badge';
+import { QueryError } from '@/components/query-error';
+import { Skeleton } from '@/components/ui/skeleton';
import { StatusDot } from '@/components/ui/status-dot';
import { Text } from '@/components/ui/text';
import { agentColor, toneColor } from '@/lib/agent-color';
@@ -51,7 +53,7 @@ export function DashboardHero({ name, status, uptime }: Readonly
-
+
void;
+ /** A background status poll failed, so this degraded reading is stale. */
+ stale?: boolean;
+ onRetry?: () => void;
+ isRetrying?: boolean;
};
-export function ServiceDegradedBanner({ onPress }: Readonly) {
+function ServiceDegradedBanner({
+ onPress,
+ stale,
+ onRetry,
+ isRetrying,
+}: Readonly) {
const colors = useThemeColors();
const danger = toneColor('danger');
return (
-
-
-
+
+
+
+
+
+
+ Service degraded — tap to view status
+
+
+ {stale && onRetry ? (
+
+ {isRetrying ? (
+
+ ) : (
+
+ )}
+
+ ) : null}
+
+ );
+}
+
+type DashboardServiceStatusProps = {
+ isError: boolean;
+ isFetching: boolean;
+ isDegraded: boolean;
+ onRetry: () => void;
+ onOpenStatusPage: () => void;
+};
+
+/**
+ * The service-degraded check is optional context. A known-degraded reading
+ * takes precedence even if the latest background poll failed — that stale
+ * "degraded" signal is more useful than a generic error, so we keep showing
+ * it (with a retry affordance) instead of discarding it. Only fall back to
+ * the generic error row when there's no degraded data at all.
+ */
+export function DashboardServiceStatus({
+ isError,
+ isFetching,
+ isDegraded,
+ onRetry,
+ onOpenStatusPage,
+}: Readonly) {
+ if (isDegraded) {
+ return (
+
+ );
+ }
+ if (isError) {
+ return (
+
+
-
- Service degraded — tap to view status
-
-
+ );
+ }
+ return null;
+}
+
+/** Row-by-row skeleton for a `StatusCard` group ("Gateway Process" or "Resources"). */
+export function StatusCardGroupSkeleton({ rows }: Readonly<{ rows: number }>) {
+ return (
+
+
+ {Array.from({ length: rows }, (_, index) => (
+
+
+
+
+ ))}
+
);
}
@@ -106,7 +204,10 @@ export function DangerZone({ pending, onDestroy }: Readonly) {
{pending ? (
@@ -117,7 +218,7 @@ export function DangerZone({ pending, onDestroy }: Readonly) {
)}
- {pending ? 'Destroying…' : 'Destroy Instance'}
+ {pending ? 'Destroying…' : 'Destroy instance'}
diff --git a/apps/mobile/src/components/kiloclaw/empty-state-content.tsx b/apps/mobile/src/components/kiloclaw/empty-state-content.tsx
index d016f92a6a..21ef7a0735 100644
--- a/apps/mobile/src/components/kiloclaw/empty-state-content.tsx
+++ b/apps/mobile/src/components/kiloclaw/empty-state-content.tsx
@@ -44,7 +44,7 @@ export function EmptyStateContent({
);
}
diff --git a/apps/mobile/src/components/kiloclaw/instance-card.tsx b/apps/mobile/src/components/kiloclaw/instance-card.tsx
index 2cc02ca4d5..411308c272 100644
--- a/apps/mobile/src/components/kiloclaw/instance-card.tsx
+++ b/apps/mobile/src/components/kiloclaw/instance-card.tsx
@@ -1,6 +1,6 @@
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'expo-router';
-import { Settings2 } from 'lucide-react-native';
+import { AlertTriangle, ExternalLink, Settings2 } from 'lucide-react-native';
import { Pressable, View } from 'react-native';
import { isTransitionalStatus, statusLabel, statusTone } from '@/components/kiloclaw/status-badge';
@@ -11,6 +11,11 @@ import { useKiloClawStatus, useKiloClawStatusQueryKey } from '@/lib/hooks/use-ki
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { chatSandboxPath } from '@/lib/kilo-chat-routes';
+export type KiloClawCardAccessIssue = {
+ label: string;
+ onOpen: () => void;
+};
+
type KiloClawCardProps = {
instance: {
sandboxId: string;
@@ -24,6 +29,8 @@ type KiloClawCardProps = {
unreadCount?: number;
onPress?: (sandboxId: string) => void;
onSettingsPress?: (sandboxId: string) => void;
+ /** Account-wide billing/access issue affecting this instance (personal instances only). */
+ accessIssue?: KiloClawCardAccessIssue | null;
};
type CachedStatus = NonNullable['data']>;
@@ -37,11 +44,26 @@ function firstLetter(name: string): string {
return trimmed.length > 0 ? (trimmed[0]?.toUpperCase() ?? 'K') : 'K';
}
+function resolveAccessibilityLabel(
+ displayName: string,
+ unreadCount: number,
+ accessIssue: KiloClawCardAccessIssue | null | undefined
+): string {
+ if (accessIssue) {
+ return `${displayName} needs attention: ${accessIssue.label}`;
+ }
+ if (unreadCount > 0) {
+ return `Open ${displayName}, ${unreadCount} unread ${unreadCount === 1 ? 'message' : 'messages'}`;
+ }
+ return `Open ${displayName}`;
+}
+
export function KiloClawCard({
instance,
unreadCount = 0,
onPress,
onSettingsPress,
+ accessIssue,
}: Readonly) {
const router = useRouter();
const colors = useThemeColors();
@@ -67,14 +89,12 @@ export function KiloClawCard({
const rawStatus = status?.status ?? instance.status ?? 'offline';
const tone = statusTone(rawStatus);
const label = statusLabel(rawStatus);
- const tapDisabled = isTransitionalStatus(rawStatus);
+ const tapDisabled = isTransitionalStatus(rawStatus) || Boolean(accessIssue);
const hue = agentColor(displayName);
const hasUnread = unreadCount > 0;
- const accessibilityLabel = hasUnread
- ? `Open ${displayName}, ${unreadCount} unread ${unreadCount === 1 ? 'message' : 'messages'}`
- : `Open ${displayName}`;
+ const accessibilityLabel = resolveAccessibilityLabel(displayName, unreadCount, accessIssue);
const handlePress = () => {
if (onPress) {
@@ -88,13 +108,20 @@ export function KiloClawCard({
onSettingsPress?.(instance.sandboxId);
};
+ const handleAccessIssuePress = () => {
+ accessIssue?.onOpen();
+ };
+
return (
-
+
@@ -119,14 +146,14 @@ export function KiloClawCard({
-
+
{label}
{hasUnread ? (
-
-
+
+
{formatUnreadCount(unreadCount)}
@@ -143,6 +170,20 @@ export function KiloClawCard({
) : null}
+ {accessIssue ? (
+
+
+
+ {accessIssue.label}
+
+
+
+ ) : null}
);
}
diff --git a/apps/mobile/src/components/kiloclaw/instance-context-boundary.tsx b/apps/mobile/src/components/kiloclaw/instance-context-boundary.tsx
new file mode 100644
index 0000000000..01378a44b8
--- /dev/null
+++ b/apps/mobile/src/components/kiloclaw/instance-context-boundary.tsx
@@ -0,0 +1,65 @@
+import { type Href, useRouter } from 'expo-router';
+import { SearchX } from 'lucide-react-native';
+import { View } from 'react-native';
+
+import { EmptyState } from '@/components/empty-state';
+import { QueryError } from '@/components/query-error';
+import { ScreenHeader } from '@/components/screen-header';
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { type InstanceContextResult } from '@/lib/hooks/use-instance-context';
+
+type Props = {
+ title: string;
+ context: InstanceContextResult;
+};
+
+/**
+ * Renders the full-screen shell (background + `ScreenHeader`) for the
+ * terminal states of `useInstanceContext`: an error with retry, or an
+ * "instance not found" empty state (destroyed instance / stale deep link).
+ * Callers only reach this for `error`/`not_found` — `loading`/`ready` are
+ * handled by the screen itself.
+ */
+export function InstanceContextBoundary({ title, context }: Readonly) {
+ const router = useRouter();
+
+ if (context.status === 'error') {
+ return (
+
+
+
+ {
+ context.refetch();
+ }}
+ />
+
+
+ );
+ }
+
+ return (
+
+
+
+ {
+ router.replace('/(app)/(tabs)/(1_kiloclaw)' as Href);
+ }}
+ >
+ Back to instances
+
+ }
+ />
+
+
+ );
+}
diff --git a/apps/mobile/src/components/kiloclaw/instance-controls.tsx b/apps/mobile/src/components/kiloclaw/instance-controls.tsx
index 7eb36eeec2..99d2bac4d5 100644
--- a/apps/mobile/src/components/kiloclaw/instance-controls.tsx
+++ b/apps/mobile/src/components/kiloclaw/instance-controls.tsx
@@ -10,14 +10,41 @@ type InstanceControlsProps = {
mutations: ReturnType;
};
+// Statuses where the backend is already mid-transition — starting or
+// redeploying now would race an in-flight lifecycle change. Anything NOT in
+// this set (including 'stopped', 'provisioned', 'crashed', and any
+// unrecognized/null status) is fair game to start. Redeploy is additionally
+// allowed while 'running' (the only status this set adds beyond redeploy's
+// own blocking list).
+const START_BLOCKING_STATUSES = new Set([
+ 'running',
+ 'starting',
+ 'restarting',
+ 'stopping',
+ 'shutting_down',
+ 'destroying',
+ 'recovering',
+ 'restoring',
+]);
+
export function InstanceControls({ status, mutations }: Readonly) {
- const canStart = status === 'stopped' || status === 'provisioned';
+ const canStart = status == null || !START_BLOCKING_STATUSES.has(status);
const canStop = status === 'running';
const canRestartOpenClaw = status === 'running';
- const canRedeploy = status === 'running' || status === 'stopped' || status === 'provisioned';
+ const canRedeploy = canStart || status === 'running';
+
+ // Only one lifecycle mutation should ever be in flight at a time — while
+ // any of these is pending (including destroy, initiated from DangerZone),
+ // disable the rest so they can't race each other.
+ const isLifecycleBusy =
+ mutations.start.isPending ||
+ mutations.stop.isPending ||
+ mutations.restartOpenClaw.isPending ||
+ mutations.restartMachine.isPending ||
+ mutations.destroy.isPending;
const handleStart = () => {
- Alert.alert('Start Instance', 'Are you sure you want to start this instance?', [
+ Alert.alert('Start instance', 'Are you sure you want to start this instance?', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Start',
@@ -30,7 +57,7 @@ export function InstanceControls({ status, mutations }: Readonly {
- Alert.alert('Stop Instance', 'Are you sure you want to stop this instance?', [
+ Alert.alert('Stop instance', 'Are you sure you want to stop this instance?', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Stop',
@@ -57,7 +84,7 @@ export function InstanceControls({ status, mutations }: Readonly {
- Alert.alert('Redeploy Instance', 'Are you sure you want to redeploy this instance?', [
+ Alert.alert('Redeploy instance', 'Are you sure you want to redeploy this instance?', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Redeploy',
@@ -74,32 +101,36 @@ export function InstanceControls({ status, mutations }: Readonly
diff --git a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx
index 2823adb6df..7299b8c56f 100644
--- a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx
+++ b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx
@@ -1,17 +1,21 @@
import * as Haptics from 'expo-haptics';
import { Plus } from 'lucide-react-native';
-import { RefreshControl, ScrollView, View } from 'react-native';
+import { RefreshControl, View } from 'react-native';
import Animated, { FadeIn } from 'react-native-reanimated';
import { badgeBucketForInstance } from '@kilocode/notifications';
-import { KiloClawCard } from '@/components/kiloclaw/instance-card';
+import { KiloClawCard, type KiloClawCardAccessIssue } from '@/components/kiloclaw/instance-card';
import { ScreenHeader } from '@/components/screen-header';
import { Button } from '@/components/ui/button';
import { Eyebrow } from '@/components/ui/eyebrow';
import { Text } from '@/components/ui/text';
+import { TabScreenScrollView } from '@/components/tab-screen';
+import { type AccessRequiredSubcase } from '@/lib/analytics/onboarding-events';
+import { openExternalUrl } from '@/lib/external-link';
import { type ClawInstance } from '@/lib/hooks/use-instance-context';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { resolveAccessIssueUrl } from '@/lib/kiloclaw/access-issue';
type Props = {
instances: ClawInstance[];
@@ -22,6 +26,20 @@ type Props = {
onRefresh: () => void;
unreadByBadgeBucket?: Map;
showSectionCounts?: boolean;
+ /** Personal-account billing/access issue, if any (org instances are unaffected). */
+ personalAccessIssue?: AccessRequiredSubcase | null;
+};
+
+// Compact labels for the per-card banner. Kept separate from
+// access-required-screen's fuller copy, which is for the dedicated
+// full-screen surface, not a list row.
+const ACCESS_ISSUE_LABELS: Record = {
+ trial_expired: 'Trial ended, subscribe to keep using this instance',
+ subscription_canceled: 'Subscription inactive, resubscribe to keep using this instance',
+ subscription_past_due: 'Payment issue, update billing to keep using this instance',
+ quarantined: 'Instance quarantined, needs manual review',
+ multiple_current_conflict: 'Account needs review',
+ non_canonical_earlybird: 'Legacy plan needs review',
};
function splitInstances(instances: ClawInstance[]) {
@@ -38,6 +56,7 @@ function InstanceSection({
onSettingsPress,
unreadByBadgeBucket,
showCount,
+ accessIssue,
}: Readonly<{
title: string;
instances: ClawInstance[];
@@ -45,6 +64,7 @@ function InstanceSection({
onSettingsPress: (sandboxId: string) => void;
unreadByBadgeBucket?: Map;
showCount: boolean;
+ accessIssue?: KiloClawCardAccessIssue | null;
}>) {
if (instances.length === 0) {
return null;
@@ -68,6 +88,7 @@ function InstanceSection({
onPress={onSelect}
onSettingsPress={onSettingsPress}
unreadCount={unreadByBadgeBucket?.get(badgeBucketForInstance(instance.sandboxId)) ?? 0}
+ accessIssue={accessIssue}
/>
))}
@@ -84,9 +105,18 @@ export function InstanceListScreen({
onRefresh,
unreadByBadgeBucket,
showSectionCounts = false,
+ personalAccessIssue,
}: Readonly) {
const colors = useThemeColors();
const { personal, organizations } = splitInstances(instances);
+ const personalCardAccessIssue: KiloClawCardAccessIssue | null = personalAccessIssue
+ ? {
+ label: ACCESS_ISSUE_LABELS[personalAccessIssue],
+ onOpen: () => {
+ void openExternalUrl(resolveAccessIssueUrl(personalAccessIssue), { label: 'kilo.ai' });
+ },
+ }
+ : null;
function handleSelect(sandboxId: string) {
void Haptics.selectionAsync();
@@ -102,13 +132,20 @@ export function InstanceListScreen({
- }
+ refreshControl={
+
+ }
>
- {instances.length === 0 ? (
+ {personal.length === 0 ? (
-
+
);
diff --git a/apps/mobile/src/components/kiloclaw/model-picker.tsx b/apps/mobile/src/components/kiloclaw/model-picker.tsx
index 2d0fd41813..f4da7bf345 100644
--- a/apps/mobile/src/components/kiloclaw/model-picker.tsx
+++ b/apps/mobile/src/components/kiloclaw/model-picker.tsx
@@ -4,7 +4,7 @@ import { Pressable, View } from 'react-native';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
-import { useInstanceContext } from '@/lib/hooks/use-instance-context';
+import { instanceOrgId, useInstanceContext } from '@/lib/hooks/use-instance-context';
import { useKiloClawConfig, useKiloClawMutations } from '@/lib/hooks/use-kiloclaw-queries';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { addModelPrefix, stripModelPrefix } from '@/lib/model-id';
@@ -31,7 +31,7 @@ const AUTO_MODEL_CARDS: AutoModelCard[] = [
iconColorKey: 'agentYuki',
cost: 3,
performance: 3,
- performanceDotColor: 'bg-purple-400',
+ performanceDotColor: 'bg-agent-yuki',
},
{
id: 'kilo-auto/balanced',
@@ -42,7 +42,7 @@ const AUTO_MODEL_CARDS: AutoModelCard[] = [
iconColorKey: 'agentSky',
cost: 2,
performance: 2,
- performanceDotColor: 'bg-blue-400',
+ performanceDotColor: 'bg-agent-sky',
},
];
@@ -85,7 +85,8 @@ function PerformanceIndicator({ level, dotColor }: { level: number; dotColor: st
export function ModelPicker() {
const router = useRouter();
const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>();
- const { organizationId } = useInstanceContext(instanceId);
+ const instanceContext = useInstanceContext(instanceId);
+ const organizationId = instanceOrgId(instanceContext);
const { data: config, isLoading } = useKiloClawConfig(organizationId);
const mutations = useKiloClawMutations(organizationId);
const colors = useThemeColors();
@@ -100,7 +101,10 @@ export function ModelPicker() {
mutations.updateModel.mutate({ kilocodeDefaultModel: addModelPrefix(modelId) });
};
- if (isLoading) {
+ // Disabled queries (organizationId unresolved) have isLoading === false, so
+ // also skeleton while instance context is loading — otherwise the cards render
+ // interactive but taps silently no-op.
+ if (isLoading || instanceContext.status !== 'ready') {
return (
diff --git a/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx b/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx
index 94bceb0aa8..5ec1847f51 100644
--- a/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx
+++ b/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx
@@ -16,7 +16,6 @@ import { X } from 'lucide-react-native';
import { type ReactNode, useCallback, useEffect, useReducer } from 'react';
import { ActivityIndicator, Pressable, View } from 'react-native';
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
-import { toast } from 'sonner-native';
import { AccessRequiredScreen } from '@/components/kiloclaw/access-required-screen';
import { resolveAccessRequiredSubcase } from '@/components/kiloclaw/empty-state-content';
@@ -60,9 +59,6 @@ function uiCategoryToAnalyticsCategory(category: ProvisionErrorCategory): Provis
return category === 'access_conflict' ? 'access' : 'generic';
}
-const GENERIC_PROVISION_ERROR_MESSAGE =
- "We couldn't set up your instance just now. Please try again.";
-
function resolveHeaderTitle(
isIdentityStep: boolean,
isLateStep: boolean,
@@ -128,6 +124,15 @@ export function OnboardingFlow() {
dispatch({ type: 'instance-status-changed', status: instanceStatus });
}, [instanceStatus]);
+ // Feed hard query errors into the machine once provisioning is underway —
+ // an errored status/gateway-ready poll is a terminal signal, not silent
+ // "keep waiting" (see `getProvisioningTerminalReason`).
+ useEffect(() => {
+ if (state.provisionStarted && (statusQuery.isError || gatewayReadyQuery.isError)) {
+ dispatch({ type: 'provisioning-query-errored' });
+ }
+ }, [state.provisionStarted, statusQuery.isError, gatewayReadyQuery.isError]);
+
useEffect(() => {
if (!gatewayReadyData) {
return;
@@ -170,10 +175,18 @@ export function OnboardingFlow() {
// skip re-provisioning: the DO row is live and the subsequent `channels-skipped`
// step-save effects will re-apply any identity / exec-preset changes idempotently.
const alreadyProvisioned = state.provisionSuccess && state.sandboxId !== null;
+ const instanceStopped = state.instanceStatus === 'stopped';
+ const startInstanceMutate = mutations.start.mutate;
const handleStart = useCallback(
(userLocation: string | null) => {
if (alreadyProvisioned) {
dispatch({ type: 'start-requested' });
+ // A stopped instance never restarts on its own — without this the
+ // `instance_stopped` terminal state's "Try again" just loops back
+ // to itself (nothing re-provisions, nothing starts).
+ if (instanceStopped) {
+ startInstanceMutate(undefined);
+ }
return;
}
dispatch({ type: 'start-requested' });
@@ -190,11 +203,12 @@ export function OnboardingFlow() {
trackEvent(PROVISION_SUCCEEDED_EVENT);
},
onError: error => {
+ // No toast here: `errorCategory` drives FlowBody's inline
+ // "Something went wrong" / access-conflict screens, so a toast
+ // on top of that would just be a redundant, modal-invisible
+ // duplicate (see `flow-body.tsx`).
const category = categorizeProvisionError(error);
dispatch({ type: 'provision-failed', category });
- if (category === 'generic') {
- toast.error(GENERIC_PROVISION_ERROR_MESSAGE);
- }
trackEvent(PROVISION_FAILED_EVENT, {
category: uiCategoryToAnalyticsCategory(category),
});
@@ -202,7 +216,7 @@ export function OnboardingFlow() {
}
);
},
- [alreadyProvisioned, mutations.provision]
+ [alreadyProvisioned, instanceStopped, startInstanceMutate, mutations.provision]
);
// Save the bot identity to the instance as soon as both the user has
@@ -377,9 +391,12 @@ export function OnboardingFlow() {
// pending_settlement without an instance — the subscription is still
// activating server-side. `resolveAccessRequiredSubcase` returns null
// because this is neither an access-required nor a remediation case.
-
- Finishing setup — hang tight while we finalize your account.
-
+
+
+
+ Finishing setup — hang tight while we finalize your account.
+
+
)}
@@ -405,7 +422,7 @@ export function OnboardingFlow() {
} else if (isChannelsStep && !hasError) {
headerRight = instanceReady ? (
-
+
Ready
) : (
@@ -436,6 +453,7 @@ export function OnboardingFlow() {
onProvisioningComplete={onProvisioningComplete}
onRetry={onProvisioningRetry}
onGraceElapsed={onGraceElapsed}
+ onContinueInBackground={onDismiss}
onOpenInstance={onOpenInstance}
/>
diff --git a/apps/mobile/src/components/kiloclaw/onboarding/flow-body.tsx b/apps/mobile/src/components/kiloclaw/onboarding/flow-body.tsx
index a1c0b0109d..2656771ded 100644
--- a/apps/mobile/src/components/kiloclaw/onboarding/flow-body.tsx
+++ b/apps/mobile/src/components/kiloclaw/onboarding/flow-body.tsx
@@ -1,4 +1,4 @@
-import { AlertTriangle, ShieldAlert } from 'lucide-react-native';
+import { AlertTriangle, ExternalLink, ShieldAlert } from 'lucide-react-native';
import { View } from 'react-native';
import Animated, { FadeIn } from 'react-native-reanimated';
@@ -9,6 +9,8 @@ import { ProvisioningStep } from '@/components/kiloclaw/onboarding/provisioning-
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { toneColor } from '@/lib/agent-color';
+import { WEB_BASE_URL } from '@/lib/config';
+import { openExternalUrl } from '@/lib/external-link';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { type BotIdentity, type OnboardingState } from '@/lib/onboarding';
import { cn } from '@/lib/utils';
@@ -20,6 +22,7 @@ type FlowBodyProps = {
onProvisioningComplete: () => void;
onRetry: () => void;
onGraceElapsed: () => void;
+ onContinueInBackground: () => void;
onOpenInstance: () => void;
};
@@ -31,6 +34,7 @@ export function FlowBody(props: Readonly) {
onProvisioningComplete,
onRetry,
onGraceElapsed,
+ onContinueInBackground,
onOpenInstance,
} = props;
const colors = useThemeColors();
@@ -63,6 +67,18 @@ export function FlowBody(props: Readonly) {
to finish setting up.
+ {
+ void openExternalUrl(WEB_BASE_URL, { label: 'kilo.ai' });
+ }}
+ accessibilityRole="link"
+ >
+ Open kilo.ai
+
+
);
}
@@ -135,6 +151,7 @@ export function FlowBody(props: Readonly) {
onComplete={onProvisioningComplete}
onGraceElapsed={onGraceElapsed}
onRetry={onRetry}
+ onContinueInBackground={onContinueInBackground}
/>
);
diff --git a/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx b/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx
index 677dee87e6..3d94fc2d77 100644
--- a/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx
+++ b/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx
@@ -6,7 +6,6 @@ import { ChevronDown, ChevronRight, ChevronUp, MapPin } from 'lucide-react-nativ
import { useCallback, useRef, useState } from 'react';
import { ActivityIndicator, Alert, Pressable, ScrollView, TextInput, View } from 'react-native';
import Animated, { LinearTransition } from 'react-native-reanimated';
-import { toast } from 'sonner-native';
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
@@ -64,6 +63,16 @@ type IdentityStepProps = {
const GPS_TIMEOUT_MS = 10_000;
const GPS_COORDINATE_PRECISION = 2;
+function locationFeedbackClassName(status: 'validated' | 'service_unavailable' | 'error'): string {
+ if (status === 'error') {
+ return 'text-destructive';
+ }
+ if (status === 'service_unavailable') {
+ return 'text-warn';
+ }
+ return 'text-muted-foreground';
+}
+
async function getCurrentPositionWithTimeout(): Promise {
let triggerTimeout: (() => void) | null = null;
const timeoutPromise = new Promise((_resolve, reject) => {
@@ -111,7 +120,7 @@ export function IdentityStep({
const [isGpsLoading, setIsGpsLoading] = useState(false);
const [locationFeedback, setLocationFeedback] = useState<{
message: string;
- status: 'validated' | 'service_unavailable';
+ status: 'validated' | 'service_unavailable' | 'error';
} | null>(null);
const [validatedLocation, setValidatedLocation] = useState(null);
@@ -153,10 +162,9 @@ export function IdentityStep({
if (locationTextRef.current.trim() !== trimmed) {
return;
}
- setLocationFeedback(null);
setValidatedLocation(null);
const message = error instanceof Error ? error.message : 'Location could not be validated.';
- toast.error(message);
+ setLocationFeedback({ message, status: 'error' });
}
}, [
applyLocationText,
@@ -190,13 +198,18 @@ export function IdentityStep({
} catch (validateError) {
Sentry.captureException(validateError);
applyLocationText(coords);
- setLocationFeedback(null);
setValidatedLocation(null);
- toast.error('Could not resolve your location. You can edit it manually.');
+ setLocationFeedback({
+ message: 'Could not resolve your location. You can edit it manually.',
+ status: 'error',
+ });
}
} catch (error) {
Sentry.captureException(error);
- toast.error('Could not get your location. Enter it manually.');
+ setLocationFeedback({
+ message: 'Could not get your location. Enter it manually.',
+ status: 'error',
+ });
} finally {
setIsGpsLoading(false);
}
@@ -225,10 +238,10 @@ export function IdentityStep({
validateLocationMutate(
{ location: trimmedLocation },
{
+ // Advancing to the next step unmounts this screen either way, so
+ // there's no inline slot left to show feedback in — same as the
+ // onError path below, just continue silently.
onSuccess: result => {
- if (result.status === 'service_unavailable') {
- toast("wttr.in is down right now. We'll store your location as entered.");
- }
onContinue(identity, result.location);
},
onError: () => {
@@ -400,7 +413,11 @@ export function IdentityStep({
void handleGpsPress();
}}
disabled={isGpsLoading || isValidating}
- className="h-11 w-11 items-center justify-center rounded-xl bg-secondary active:opacity-70 disabled:opacity-50"
+ accessibilityState={{ disabled: isValidating, busy: isGpsLoading }}
+ className={cn(
+ 'h-11 w-11 items-center justify-center rounded-xl bg-secondary active:opacity-70',
+ isValidating && !isGpsLoading && 'opacity-50'
+ )}
>
{isGpsLoading ? (
@@ -410,14 +427,7 @@ export function IdentityStep({
{locationFeedback && (
-
+
{locationFeedback.message}
)}
diff --git a/apps/mobile/src/components/kiloclaw/onboarding/notifications-step.tsx b/apps/mobile/src/components/kiloclaw/onboarding/notifications-step.tsx
index 21d1de3a52..35eb9d4ec3 100644
--- a/apps/mobile/src/components/kiloclaw/onboarding/notifications-step.tsx
+++ b/apps/mobile/src/components/kiloclaw/onboarding/notifications-step.tsx
@@ -1,9 +1,8 @@
import * as SecureStore from 'expo-secure-store';
import { ChevronRight } from 'lucide-react-native';
import { useCallback, useEffect, useState } from 'react';
-import { Alert, Linking, ScrollView, View } from 'react-native';
+import { ActivityIndicator, Alert, Linking, ScrollView, View } from 'react-native';
import { useMutation } from '@tanstack/react-query';
-import { toast } from 'sonner-native';
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
@@ -30,29 +29,66 @@ export function NotificationsStep({ onComplete, botIdentity }: Readonly('checking');
+ const [permission, setPermission] = useState<'checking' | 'undetermined' | 'denied'>('checking');
+ const [isRegistering, setIsRegistering] = useState(false);
+ const [registerError, setRegisterError] = useState(null);
const botName = botIdentity?.botName ?? DEFAULT_BOT_IDENTITY.botName;
const botEmoji = botIdentity?.botEmoji ?? DEFAULT_BOT_IDENTITY.botEmoji;
- const registerToken = useMutation(
- trpc.user.registerPushToken.mutationOptions({
- onError: error => {
- toast.error(error.message);
- },
- })
+ // No onError toast here: registration failures are surfaced inline (see
+ // `registerError` below) since this step stays open on failure — the
+ // wizard modal's toasts are otherwise invisible/redundant here (see
+ // `onboarding-flow.tsx`'s removed generic-error toast for the same reason).
+ const registerToken = useMutation(trpc.user.registerPushToken.mutationOptions());
+ const registerTokenMutateAsync = registerToken.mutateAsync;
+
+ // Requests (or re-confirms) the push token and registers it with the
+ // server, then marks the prompt as seen and advances. Used by both the
+ // auto-check effect (permission already granted) and the Enable button.
+ // Never leaves the user stranded: any failure — permission request,
+ // token fetch, or server registration — lands in `registerError` with a
+ // Try again / Skip escape hatch, instead of throwing silently.
+ const completeRegistration = useCallback(
+ async (isCancelled: () => boolean = () => false) => {
+ setRegisterError(null);
+ setIsRegistering(true);
+ try {
+ const token = await registerForPushNotifications();
+ if (isCancelled()) {
+ return;
+ }
+ if (token) {
+ await registerTokenMutateAsync({ token, platform: getPlatform() });
+ if (isCancelled()) {
+ return;
+ }
+ }
+ await SecureStore.setItemAsync(NOTIFICATION_PROMPT_SEEN_KEY, 'true');
+ if (isCancelled()) {
+ return;
+ }
+ onComplete();
+ } catch (error) {
+ if (isCancelled()) {
+ return;
+ }
+ setRegisterError(
+ error instanceof Error ? error.message : 'Could not enable notifications.'
+ );
+ } finally {
+ if (!isCancelled()) {
+ setIsRegistering(false);
+ }
+ }
+ },
+ [onComplete, registerTokenMutateAsync]
);
// Re-check permission on mount and whenever the app returns to foreground.
// The user may have flipped the setting via the system Settings app after
// we deep-linked them there; picking that up on resume avoids stranding
// them on the "denied" state view.
- //
- // When permission is already granted (pre-granted, or flipped in Settings
- // after we deep-linked them there), we still need to fetch the Expo push
- // token and register it with the server — otherwise onboarding completes
- // without a server-registered token and the user never receives pushes.
- const registerTokenMutate = registerToken.mutate;
useEffect(() => {
if (!isActive) {
return undefined;
@@ -64,36 +100,23 @@ export function NotificationsStep({ onComplete, botIdentity }: Readonly cancelled);
} else {
- setStatus(permStatus);
+ setPermission(permStatus);
}
};
void check();
return () => {
cancelled = true;
};
- }, [isActive, onComplete, registerTokenMutate]);
+ }, [isActive, completeRegistration]);
const handleEnable = useCallback(async () => {
const currentStatus = await getNotificationPermissionStatus();
if (currentStatus === 'denied') {
Alert.alert(
- 'Notifications Disabled',
+ 'Notifications disabled',
'To enable notifications, turn them on in your device settings.',
[
{ text: 'Cancel', style: 'cancel' },
@@ -103,23 +126,29 @@ export function NotificationsStep({ onComplete, botIdentity }: Readonly {
- await SecureStore.setItemAsync(NOTIFICATION_PROMPT_SEEN_KEY, 'true');
- onComplete();
+ try {
+ await SecureStore.setItemAsync(NOTIFICATION_PROMPT_SEEN_KEY, 'true');
+ } catch {
+ // Skip is an explicit user choice to move on — never block it on a
+ // storage write failing.
+ } finally {
+ onComplete();
+ }
}, [onComplete]);
- if (status === 'checking') {
- return null;
+ if (permission === 'checking' || isRegistering) {
+ return (
+
+
+
+ Setting up notifications…
+
+
+ );
}
return (
@@ -155,9 +184,11 @@ export function NotificationsStep({ onComplete, botIdentity }: Readonly
+ {registerError ? {registerError} : null}
+
void handleEnable()}>
- Enable notifications
+ {registerError ? 'Try again' : 'Enable notifications'}
void handleSkip()}>
diff --git a/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx b/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx
index f6e4be0559..38729ab704 100644
--- a/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx
+++ b/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx
@@ -1,18 +1,20 @@
import {
checkGraceExpired,
- isProvisioningTerminal,
+ getProvisioningTerminalReason,
type OnboardingState,
+ type ProvisioningTerminalReason,
shouldAdvanceFromProvisioning,
} from '@/lib/onboarding';
import { AlertTriangle } from 'lucide-react-native';
import { useEffect, useMemo, useState } from 'react';
import { View } from 'react-native';
import Animated, {
+ cancelAnimation,
Easing,
FadeIn,
FadeOut,
- LinearTransition,
useAnimatedStyle,
+ useReducedMotion,
useSharedValue,
withRepeat,
withTiming,
@@ -31,25 +33,50 @@ type ProvisioningStepProps = {
onGraceElapsed: () => void;
onComplete: () => void;
onRetry: () => void;
+ onContinueInBackground: () => void;
};
-const STATUS_MESSAGE_TEMPLATES = [
- 'Renting a small corner of the cloud…',
- 'Handing {name} a name tag…',
- 'Unpacking a fresh set of prompts…',
- 'Teaching it where the kettle is…',
- 'Warming up the silicon…',
- 'Running a quick vibe check…',
- 'Pouring {name} a cup of coffee…',
- 'Double-checking the welcome memo…',
-];
-
-const STATUS_ROTATE_MS = 2600;
+// Overall wall-clock budget for provisioning before we tell the user it's
+// stalled, rather than pulsing an indefinite "waking up" message. Resets
+// per mount, which happens on every fresh entry into the provisioning step
+// (including after a retry — see `flow-body.tsx`'s `key="provisioning"`).
+const OVERALL_TIMEOUT_MS = 150_000;
const PULSE_PEAK = 1.06;
const PULSE_DURATION_MS = 1400;
-function personalizeMessages(name: string): readonly string[] {
- return STATUS_MESSAGE_TEMPLATES.map(m => m.replace('{name}', name));
+const TERMINAL_CONTENT: Record<
+ ProvisioningTerminalReason,
+ { title: string; body: (name: string) => string }
+> = {
+ query_error: {
+ title: "Couldn't check on setup",
+ body: name =>
+ `We lost the connection while checking on ${name}'s setup. It may still be running. Try again, or check back shortly.`,
+ },
+ instance_stopped: {
+ title: 'Setup stopped',
+ body: name => `${name}'s instance stopped unexpectedly during setup.`,
+ },
+ gateway_502: {
+ title: 'Something stalled',
+ body: name =>
+ `We couldn't finish setting up ${name}. Try again, or email hi@kilo.ai if this keeps happening.`,
+ },
+ timeout: {
+ title: 'Taking longer than expected',
+ body: name =>
+ `Setup for ${name} is taking longer than usual (over ${OVERALL_TIMEOUT_MS / 60_000} minutes).`,
+ },
+};
+
+function provisioningStageMessage(state: OnboardingState): string {
+ if (state.instanceStatus === 'running' && state.gatewayReady && !state.gatewaySettled) {
+ return 'Finishing setup';
+ }
+ if (state.instanceStatus === 'running' && !state.gatewayReady) {
+ return 'Connecting to the agent';
+ }
+ return 'Starting the sandbox';
}
export function ProvisioningStep({
@@ -57,6 +84,7 @@ export function ProvisioningStep({
onGraceElapsed,
onComplete,
onRetry,
+ onContinueInBackground,
}: Readonly) {
const colors = useThemeColors();
@@ -64,21 +92,25 @@ export function ProvisioningStep({
const botName = state.botIdentity?.botName ?? DEFAULT_BOT_IDENTITY.botName;
const tint = agentColor(botEmoji);
- const messages = useMemo(() => personalizeMessages(botName), [botName]);
- const [messageIndex, setMessageIndex] = useState(0);
- useEffect(() => {
- const id = setInterval(() => {
- setMessageIndex(i => (i + 1) % messages.length);
- }, STATUS_ROTATE_MS);
- return () => {
- clearInterval(id);
- };
- }, [messages.length]);
+ // Overall wall-clock timeout: local to this component since it's the only
+ // producer and consumer. Resets naturally on retry, same as `enteredAtMs`
+ // below — a retry sends `state.step` back to `identity`, which unmounts
+ // this component (see `key="provisioning"` in `flow-body.tsx`), so both
+ // reset fresh on the next entry into provisioning.
+ const [timedOut, setTimedOut] = useState(false);
+
+ const terminalReason = getProvisioningTerminalReason(state, timedOut);
+ const stageMessage = useMemo(() => provisioningStageMessage(state), [state]);
// Gentle breathing pulse on the avatar tile — signals "actively working"
- // without the spinner-in-the-middle-of-nowhere look.
+ // without the spinner-in-the-middle-of-nowhere look. Static tile when
+ // Reduce Motion is on, same as Skeleton/SpinningIcon.
+ const reducedMotion = useReducedMotion();
const pulse = useSharedValue(1);
useEffect(() => {
+ if (reducedMotion) {
+ return undefined;
+ }
pulse.value = withRepeat(
withTiming(PULSE_PEAK, {
duration: PULSE_DURATION_MS,
@@ -87,28 +119,44 @@ export function ProvisioningStep({
-1,
true
);
- }, [pulse]);
+ return () => {
+ cancelAnimation(pulse);
+ };
+ }, [pulse, reducedMotion]);
const pulseStyle = useAnimatedStyle(() => ({
transform: [{ scale: pulse.value }],
}));
- // 502-grace poll: tick once per second while we're holding a 502 and the
- // grace window hasn't elapsed. Checked against the pure helper so the
- // timing logic stays testable.
+ // The overall-timeout clock starts at mount, which happens fresh every
+ // time this step is (re)entered — see the `key="provisioning"` on the
+ // FlowBody branch that renders this component.
+ const [enteredAtMs] = useState(() => Date.now());
+
+ // Single 1s poll while provisioning is live and not yet terminal: checks
+ // both the 502-grace sub-machine (pure `checkGraceExpired` helper) and the
+ // overall wall-clock budget, acting on whichever fires.
const { first502AtMs, gateway502Expired } = state;
useEffect(() => {
- if (first502AtMs === null || gateway502Expired) {
+ if (terminalReason !== null) {
return undefined;
}
const interval = setInterval(() => {
- if (checkGraceExpired({ first502AtMs }, Date.now())) {
+ const nowMs = Date.now();
+ if (
+ first502AtMs !== null &&
+ !gateway502Expired &&
+ checkGraceExpired({ first502AtMs }, nowMs)
+ ) {
onGraceElapsed();
}
+ if (!timedOut && nowMs - enteredAtMs >= OVERALL_TIMEOUT_MS) {
+ setTimedOut(true);
+ }
}, 1000);
return () => {
clearInterval(interval);
};
- }, [first502AtMs, gateway502Expired, onGraceElapsed]);
+ }, [terminalReason, first502AtMs, gateway502Expired, timedOut, enteredAtMs, onGraceElapsed]);
// Advance to the done step once the instance + gateway gate holds. Step
// saves are dispatched from OnboardingFlow and their apply is guaranteed by
@@ -121,8 +169,9 @@ export function ProvisioningStep({
}
}, [advance, onComplete]);
- if (isProvisioningTerminal(state)) {
+ if (terminalReason !== null) {
const danger = toneColor('danger');
+ const content = TERMINAL_CONTENT[terminalReason];
return (
Provisioning
- Something stalled
+ {content.title}
- We couldn't finish setting up {botName}. Try again, or email hi@kilo.ai if this
- keeps happening.
+ {content.body(botName)}
-
- Try again
-
+
+
+ Try again
+
+
+ Continue in background
+
+
);
}
- const message = messages[messageIndex] ?? messages[0];
-
return (
- Setting up
+ Provisioning
- Waking up {botName}
+ Setting up {botName}
-
-
-
- {message}
-
-
+
+
+ {stageMessage}
+
- You can close this — we'll keep working in the background.
+ Usually takes under a minute. You can close this — we'll keep working in the
+ background.
);
diff --git a/apps/mobile/src/components/kiloclaw/rename-instance-modal.tsx b/apps/mobile/src/components/kiloclaw/rename-instance-modal.tsx
deleted file mode 100644
index 7897ecfe2f..0000000000
--- a/apps/mobile/src/components/kiloclaw/rename-instance-modal.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import { useEffect, useRef } from 'react';
-import { Modal, Platform, Pressable, TextInput, View } from 'react-native';
-
-import { Button } from '@/components/ui/button';
-import { Text } from '@/components/ui/text';
-import { useThemeColors } from '@/lib/hooks/use-theme-colors';
-
-type RenameInstanceModalProps = {
- defaultName: string;
- onSubmit: (name: string) => void;
- onClose: () => void;
-};
-
-export function RenameInstanceModal({
- defaultName,
- onSubmit,
- onClose,
-}: Readonly) {
- const colors = useThemeColors();
- const nameRef = useRef(defaultName);
- const inputRef = useRef(null);
-
- // autoFocus doesn't reliably raise the keyboard inside Modal on Android
- useEffect(() => {
- if (Platform.OS !== 'android') {
- return undefined;
- }
- const timer = setTimeout(() => {
- inputRef.current?.focus();
- }, 100);
- return () => {
- clearTimeout(timer);
- };
- }, []);
-
- return (
-
-
-
- {
- e.stopPropagation();
- }}
- >
- Rename Instance
- {
- nameRef.current = val;
- }}
- autoFocus={Platform.OS !== 'android'}
- maxLength={50}
- />
-
-
- Cancel
-
- {
- const trimmed = nameRef.current.trim();
- if (trimmed) {
- onSubmit(trimmed);
- }
- onClose();
- }}
- >
- Save
-
-
-
-
-
- );
-}
diff --git a/apps/mobile/src/components/kiloclaw/settings-card.tsx b/apps/mobile/src/components/kiloclaw/settings-card.tsx
index 4d2c6f899e..053688cf5a 100644
--- a/apps/mobile/src/components/kiloclaw/settings-card.tsx
+++ b/apps/mobile/src/components/kiloclaw/settings-card.tsx
@@ -1,12 +1,22 @@
-import { Check, ChevronDown, ChevronUp, Trash2 } from 'lucide-react-native';
+import {
+ Check,
+ ChevronDown,
+ ChevronUp,
+ ExternalLink,
+ Eye,
+ EyeOff,
+ Trash2,
+} from 'lucide-react-native';
import { useCallback, useRef, useState } from 'react';
-import { ActivityIndicator, Alert, TextInput, View } from 'react-native';
+import { ActivityIndicator, Alert, Pressable, View } from 'react-native';
import Animated, { FadeIn } from 'react-native-reanimated';
import { toast } from 'sonner-native';
import { CATALOG_ICONS } from '@/components/icons';
import { Button } from '@/components/ui/button';
+import { FormField } from '@/components/ui/form-field';
import { Text } from '@/components/ui/text';
+import { openExternalUrl } from '@/lib/external-link';
import {
type useKiloClawMutations,
type useKiloClawSecretCatalog,
@@ -14,6 +24,7 @@ import {
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
type CatalogItem = NonNullable['data']>[number];
+type CatalogField = CatalogItem['fields'][number];
function ExpandButton({
expanded,
@@ -30,6 +41,50 @@ function ExpandButton({
);
}
+function SecretField({
+ field,
+ configured,
+ onChangeText,
+}: Readonly<{
+ field: CatalogField;
+ configured: boolean;
+ onChangeText: (val: string) => void;
+}>) {
+ const colors = useThemeColors();
+ const [revealed, setRevealed] = useState(false);
+
+ return (
+
+
+ {
+ setRevealed(r => !r);
+ }}
+ accessibilityRole="button"
+ accessibilityLabel={revealed ? `Hide ${field.label}` : `Show ${field.label}`}
+ className="absolute bottom-0 right-0 h-10 w-10 items-center justify-center active:opacity-70"
+ >
+ {revealed ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
function ExpandedFields({
item,
canSave,
@@ -44,30 +99,39 @@ function ExpandedFields({
onSave: () => void;
}>) {
const colors = useThemeColors();
+ const guideUrl = item.guideUrl;
return (
- {item.allFieldsRequired && item.fields.length > 1 && (
+ {item.allFieldsRequired && item.fields.length > 1 && !item.configured && (
All fields are required to connect {item.label}.
)}
+ {guideUrl && (
+ {
+ void openExternalUrl(guideUrl, { label: item.guideText ?? 'setup guide' });
+ }}
+ accessibilityRole="link"
+ accessibilityLabel={item.guideText ?? `${item.label} setup guide`}
+ className="flex-row items-center gap-1.5 active:opacity-70"
+ >
+
+
+ {item.guideText ?? 'Setup guide'}
+
+
+ )}
{item.fields.map(field => (
-
- {field.label}
- {
- onFieldChange(field.key, val);
- }}
- autoCapitalize="none"
- autoCorrect={false}
- autoComplete="off"
- returnKeyType="done"
- />
-
+ {
+ onFieldChange(field.key, val);
+ }}
+ />
))}
{isSaving ? (
@@ -105,9 +169,16 @@ export function SettingsCard({
const updateCanSave = useCallback(() => {
const vals = fieldValuesRef.current;
const filled = item.fields.filter(f => (vals[f.key] ?? '').trim().length > 0);
- const next = item.allFieldsRequired ? filled.length === item.fields.length : filled.length > 0;
+ // Initial connection needs every field at once when the provider requires
+ // it; once connected, a single changed field is a valid rotation — the
+ // backend keeps whichever fields are omitted from the patch (see
+ // patchSecrets router comment).
+ const next =
+ item.configured || !item.allFieldsRequired
+ ? filled.length > 0
+ : filled.length === item.fields.length;
setCanSave(next);
- }, [item.fields, item.allFieldsRequired]);
+ }, [item.fields, item.allFieldsRequired, item.configured]);
function handleSave() {
const secrets: Record = {};
@@ -165,6 +236,8 @@ export function SettingsCard({
setExpanded(prev => !prev);
}, []);
+ const helpUrl = item.helpUrl;
+
return (
{/* Header row */}
@@ -172,13 +245,25 @@ export function SettingsCard({
{ItemIcon && }
{item.label}
- {item.helpText && {item.helpText}}
+ {item.helpText &&
+ (helpUrl ? (
+ {
+ void openExternalUrl(helpUrl, { label: item.label });
+ }}
+ accessibilityRole="link"
+ accessibilityLabel={item.helpText}
+ className="active:opacity-70"
+ >
+ {item.helpText}
+
+ ) : (
+ {item.helpText}
+ ))}
{item.configured ? (
-
-
- Connected
-
+
+ Connected
) : (
diff --git a/apps/mobile/src/components/kiloclaw/settings-list.tsx b/apps/mobile/src/components/kiloclaw/settings-list.tsx
index 0031323846..48686a6be1 100644
--- a/apps/mobile/src/components/kiloclaw/settings-list.tsx
+++ b/apps/mobile/src/components/kiloclaw/settings-list.tsx
@@ -41,19 +41,19 @@ const SETTINGS_ITEMS: SettingsItem[] = [
},
{
icon: Link2,
- label: 'Device Pairing',
+ label: 'Device pairing',
description: 'Approve device requests',
path: 'settings/device-pairing',
},
{
icon: Shield,
- label: 'Execution Policy',
+ label: 'Execution policy',
description: 'Security settings',
path: 'settings/exec-policy',
},
{
icon: Pin,
- label: 'Version Pinning',
+ label: 'Version pinning',
description: 'Pin to a specific version',
path: 'settings/version-pin',
},
diff --git a/apps/mobile/src/components/kiloclaw/status-badge.tsx b/apps/mobile/src/components/kiloclaw/status-badge.tsx
index 136acb4d0d..b80659c923 100644
--- a/apps/mobile/src/components/kiloclaw/status-badge.tsx
+++ b/apps/mobile/src/components/kiloclaw/status-badge.tsx
@@ -61,7 +61,7 @@ export function StatusBadge({
return (
-
+
{label}
diff --git a/apps/mobile/src/components/kiloclaw/version-pin-row.tsx b/apps/mobile/src/components/kiloclaw/version-pin-row.tsx
new file mode 100644
index 0000000000..77df8a25c0
--- /dev/null
+++ b/apps/mobile/src/components/kiloclaw/version-pin-row.tsx
@@ -0,0 +1,119 @@
+import { Check } from 'lucide-react-native';
+import { TextInput, View } from 'react-native';
+import Animated, { FadeIn } from 'react-native-reanimated';
+
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { type useKiloClawAvailableVersions } from '@/lib/hooks/use-kiloclaw-queries';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { parseTimestamp, timeAgo } from '@/lib/utils';
+
+export type VersionItem = NonNullable<
+ ReturnType['data']
+>['items'][number];
+
+export function VersionPinRow({
+ item,
+ isPinned,
+ isLatest,
+ isDraftOpen,
+ isPinMutating,
+ isConfirmingThis,
+ isPinnedByAdmin,
+ adminPinLabel,
+ onToggle,
+ onFocusReason,
+ onReasonChange,
+ onConfirm,
+}: Readonly<{
+ item: VersionItem;
+ isPinned: boolean;
+ isLatest: boolean;
+ isDraftOpen: boolean;
+ isPinMutating: boolean;
+ isConfirmingThis: boolean;
+ isPinnedByAdmin: boolean;
+ adminPinLabel: string | null;
+ onToggle: () => void;
+ onFocusReason: () => void;
+ onReasonChange: (val: string) => void;
+ onConfirm: () => void;
+}>) {
+ const colors = useThemeColors();
+ const publishedAgo = item.published_at ? timeAgo(parseTimestamp(item.published_at)) : undefined;
+ const showVariant = item.variant && item.variant !== 'default';
+
+ return (
+
+
+
+
+ {item.openclaw_version}
+ {isLatest && (
+
+ latest
+
+ )}
+
+ {Boolean(publishedAgo ?? showVariant) && (
+
+ {[publishedAgo, showVariant ? item.variant : null].filter(Boolean).join(' · ')}
+
+ )}
+
+ {isPinned ? (
+
+ ) : (
+
+ {isDraftOpen ? 'Cancel' : 'Pin'}
+
+ )}
+
+ {isDraftOpen && (
+
+
+ Reason (optional)
+ {
+ if (val.length <= 500) {
+ onReasonChange(val);
+ }
+ }}
+ autoCapitalize="sentences"
+ autoCorrect
+ multiline
+ maxLength={500}
+ editable={!isConfirmingThis}
+ accessibilityState={{ busy: isConfirmingThis }}
+ />
+ {isPinnedByAdmin && adminPinLabel && (
+
+ This replaces the admin-set pin (currently {adminPinLabel}).
+
+ )}
+
+ {!isConfirmingThis && }
+
+ {isPinnedByAdmin ? 'Replace Admin Pin' : 'Confirm Pin'}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/mobile/src/components/kiloclaw/version-pin-status-card.tsx b/apps/mobile/src/components/kiloclaw/version-pin-status-card.tsx
new file mode 100644
index 0000000000..0a2db8e947
--- /dev/null
+++ b/apps/mobile/src/components/kiloclaw/version-pin-status-card.tsx
@@ -0,0 +1,75 @@
+import { View } from 'react-native';
+
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import {
+ type useKiloClawLatestVersion,
+ type useKiloClawMyPin,
+} from '@/lib/hooks/use-kiloclaw-queries';
+
+type MyPin = NonNullable['data']>;
+type LatestVersion = NonNullable['data']>;
+
+export function VersionPinStatusCard({
+ myPin,
+ latestVersion,
+ isPinnedByAdmin,
+ isPinMutating,
+ isRemovingPin,
+ onUnpin,
+}: Readonly<{
+ myPin: MyPin | null | undefined;
+ latestVersion: LatestVersion | null | undefined;
+ isPinnedByAdmin: boolean;
+ isPinMutating: boolean;
+ isRemovingPin: boolean;
+ onUnpin: () => void;
+}>) {
+ return (
+
+ {myPin ? (
+ <>
+
+
+
+ Pinned to {myPin.openclaw_version ?? myPin.image_tag}
+
+ {myPin.reason && (
+
+ {myPin.reason}
+
+ )}
+
+ {!isPinnedByAdmin && (
+
+ Unpin
+
+ )}
+
+ {isPinnedByAdmin && (
+
+ Pinned by admin — contact your admin to change.
+
+ )}
+ >
+ ) : (
+
+
+ Following latest
+
+ {latestVersion && (
+
+ {latestVersion.openclawVersion}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/apps/mobile/src/components/login-screen.tsx b/apps/mobile/src/components/login-screen.tsx
index 26488e0a45..2992119d06 100644
--- a/apps/mobile/src/components/login-screen.tsx
+++ b/apps/mobile/src/components/login-screen.tsx
@@ -1,6 +1,6 @@
import * as Clipboard from 'expo-clipboard';
import { ExternalLink } from 'lucide-react-native';
-import { useEffect } from 'react';
+import { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, ScrollView, View } from 'react-native';
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
import { toast } from 'sonner-native';
@@ -28,46 +28,55 @@ function errorMessage(status: string, fallback: string | undefined) {
}
}
-function AuthButtons({ start }: { start: (mode: 'signin' | 'signup') => Promise }) {
- return (
- <>
- {
- void start('signin');
- }}
- accessibilityLabel="Sign in with browser"
- >
- Sign In
-
- {
- void start('signup');
- }}
- accessibilityLabel="Create a new account"
- >
- Create Account
-
- >
- );
-}
-
export function LoginScreen() {
const { signIn } = useAuth();
const { status, token, code, error, verificationUrl, start, cancel, openBrowser } =
useDeviceAuth();
const colors = useThemeColors();
+ const [persistError, setPersistError] = useState(undefined);
+
+ const persistToken = useCallback(
+ async (tokenValue: string) => {
+ setPersistError(undefined);
+ try {
+ await signIn(tokenValue);
+ } catch {
+ setPersistError('Could not complete sign in. Please try again.');
+ }
+ },
+ [signIn]
+ );
useEffect(() => {
if (status === 'approved' && token) {
- void signIn(token);
+ void persistToken(token);
}
- }, [status, token, signIn]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- persistToken is stable except for signIn identity; only re-run on a newly approved token
+ }, [status, token]);
if (status === 'approved') {
- return ;
+ if (persistError) {
+ return (
+
+ {persistError}
+ {
+ if (token) {
+ void persistToken(token);
+ }
+ }}
+ accessibilityLabel="Retry sign in"
+ >
+ Retry
+
+
+ );
+ }
+ return (
+
+
+
+ );
}
return (
@@ -120,7 +129,7 @@ export function LoginScreen() {
accessibilityLabel="Open sign-in page in browser"
>
- Open
+ Open in browser
- Copy Link
+ Copy link
@@ -150,6 +159,9 @@ export function LoginScreen() {
>
Starting sign in...
+
+ Cancel
+
)}
@@ -162,7 +174,7 @@ export function LoginScreen() {
{errorMessage(status, error)}
-
+
)}
diff --git a/apps/mobile/src/components/login/idle-auth.tsx b/apps/mobile/src/components/login/idle-auth.tsx
index a4d786b29b..46d75cd27c 100644
--- a/apps/mobile/src/components/login/idle-auth.tsx
+++ b/apps/mobile/src/components/login/idle-auth.tsx
@@ -5,19 +5,19 @@ import {
isAvailableAsync as isAppleAuthAvailableAsync,
} from 'expo-apple-authentication';
import { useEffect, useRef, useState } from 'react';
-import { ActivityIndicator, Platform, TextInput, useColorScheme, View } from 'react-native';
+import { ActivityIndicator, Platform, useColorScheme, View } from 'react-native';
+import { toast } from 'sonner-native';
import { EmailOtpForm } from '@/components/login/email-otp-form';
import { GoogleLogo } from '@/components/login/google-logo';
import { Button } from '@/components/ui/button';
+import { FormField } from '@/components/ui/form-field';
import { Text } from '@/components/ui/text';
import { useNativeAuth } from '@/lib/auth/use-native-auth';
-import { useThemeColors } from '@/lib/hooks/use-theme-colors';
export function IdleAuth({
start,
}: Readonly<{ start: (mode: 'signin' | 'signup') => Promise }>) {
- const colors = useThemeColors();
const colorScheme = useColorScheme();
const {
busy,
@@ -90,7 +90,12 @@ export function IdleAuth({
void verifyEmailCode(emailRef.current, code);
}}
onResend={() => {
- void requestEmailCode(emailRef.current);
+ void (async () => {
+ const ok = await requestEmailCode(emailRef.current);
+ if (ok) {
+ toast.success('Code sent');
+ }
+ })();
}}
onBack={() => {
setView('main');
@@ -102,23 +107,28 @@ export function IdleAuth({
return (
{showApple && (
- {
- if (!authBusy) {
- void signInWithApple();
+
+
+ cornerRadius={8}
+ // eslint-disable-next-line react-native/no-inline-styles -- AppleAuthenticationButton isn't NativeWind-aware; height/width must be set via style, not className
+ style={{ height: 44, width: '100%' }}
+ onPress={() => {
+ if (!authBusy) {
+ void signInWithApple();
+ }
+ }}
+ accessibilityLabel="Sign in with Apple"
+ />
+
)}
{googleConfigured && (
@@ -145,17 +155,17 @@ export function IdleAuth({
)}
- {
emailRef.current = value;
}}
- accessibilityLabel="Email address"
/>
void }>;
+
+function InlineRetry({ label, color, onPress }: InlineRetryProps) {
+ return (
+
+
+ Retry
+
+ );
+}
+
export function NotificationsCard() {
const trpc = useTRPC();
const queryClient = useQueryClient();
@@ -29,7 +45,14 @@ export function NotificationsCard() {
const { token: authToken } = useAuth();
const isAuthenticated = authToken != null;
- const { data: permissionGranted = false, isLoading: permissionLoading } = useQuery({
+ const [isTogglingPermission, setIsTogglingPermission] = useState(false);
+
+ const {
+ data: permissionGranted = false,
+ isLoading: permissionLoading,
+ isError: permissionError,
+ refetch: refetchPermission,
+ } = useQuery({
queryKey: permissionQueryKey,
queryFn: async () => {
const status = await getNotificationPermissionStatus();
@@ -37,16 +60,28 @@ export function NotificationsCard() {
},
});
- const { data: deviceToken, isLoading: deviceTokenLoading } = useQuery({
+ const {
+ data: deviceToken,
+ isLoading: deviceTokenLoading,
+ isError: deviceTokenError,
+ refetch: refetchDeviceToken,
+ } = useQuery({
queryKey: deviceTokenQueryKey,
queryFn: getDevicePushToken,
enabled: permissionGranted,
});
- const { data: pushTokens, isLoading: tokensLoading } = useQuery({
+ const {
+ data: pushTokens,
+ isLoading: tokensLoading,
+ isError: pushTokensError,
+ refetch: refetchPushTokens,
+ } = useQuery({
...trpc.user.getMyPushTokens.queryOptions(),
enabled: isAuthenticated,
});
+ const chatTokensError = deviceTokenError || pushTokensError;
+ const chatLoading = permissionLoading || tokensLoading || deviceTokenLoading;
const pushTokensQueryKey = trpc.user.getMyPushTokens.queryOptions().queryKey;
const serverRegistered =
@@ -103,6 +138,8 @@ export function NotificationsCard() {
})
);
+ const chatTogglePending = registerToken.isPending || unregisterToken.isPending;
+
// Re-check permission on foreground resume
const { isActive } = useAppLifecycle();
const wasActiveRef = useRef(isActive);
@@ -119,7 +156,7 @@ export function NotificationsCard() {
const currentStatus = await getNotificationPermissionStatus();
if (currentStatus === 'denied') {
Alert.alert(
- 'Notifications Disabled',
+ 'Notifications disabled',
'To enable notifications, turn them on in your device settings.',
[
{ text: 'Cancel', style: 'cancel' },
@@ -128,11 +165,20 @@ export function NotificationsCard() {
);
return;
}
- await Notifications.requestPermissionsAsync();
- void queryClient.invalidateQueries({ queryKey: permissionQueryKey });
+ setIsTogglingPermission(true);
+ try {
+ await Notifications.requestPermissionsAsync();
+ void queryClient.invalidateQueries({ queryKey: permissionQueryKey });
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : 'Failed to request notification permission.'
+ );
+ } finally {
+ setIsTogglingPermission(false);
+ }
} else {
Alert.alert(
- 'Disable Notifications',
+ 'Disable notifications',
'To disable notifications, turn them off in your device settings.',
[
{ text: 'Cancel', style: 'cancel' },
@@ -147,10 +193,22 @@ export function NotificationsCard() {
const handleToggleChatMessages = useCallback(
async (value: boolean) => {
if (value) {
- const token = await registerForPushNotifications();
- if (token) {
- registerToken.mutate({ token, platform: getPlatform() });
+ let token: string | null = null;
+ try {
+ token = await registerForPushNotifications();
+ } catch (error) {
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : 'Registration failed. Check your notification permissions.'
+ );
+ return;
}
+ if (!token) {
+ toast.error('Registration failed. Check your notification permissions.');
+ return;
+ }
+ registerToken.mutate({ token, platform: getPlatform() });
} else if (deviceToken) {
unregisterToken.mutate({ token: deviceToken });
}
@@ -168,14 +226,27 @@ export function NotificationsCard() {
Notifications
- {permissionLoading ? (
-
- ) : (
- void handleToggleNotifications(value)}
+ {permissionLoading && }
+ {!permissionLoading && permissionError && (
+ void refetchPermission()}
/>
)}
+ {!permissionLoading && !permissionError && (
+ <>
+ {isTogglingPermission && (
+
+ )}
+ void handleToggleNotifications(value)}
+ />
+ >
+ )}
{/* Chat messages — controls DB token registration */}
@@ -183,21 +254,37 @@ export function NotificationsCard() {
className={`flex-row items-center gap-3 rounded-lg bg-secondary p-3 ${!permissionGranted ? 'opacity-40' : ''}`}
>
- Chat Messages
- {permissionLoading || tokensLoading || deviceTokenLoading ? (
-
- ) : (
- {
- if (registerToken.isPending || unregisterToken.isPending) {
- return;
- }
- void handleToggleChatMessages(value);
+ Chat messages
+ {chatLoading && }
+ {!chatLoading && chatTokensError && (
+ {
+ void refetchDeviceToken();
+ void refetchPushTokens();
}}
/>
)}
+ {!chatLoading && !chatTokensError && (
+ <>
+ {chatTogglePending && }
+ {
+ if (chatTogglePending) {
+ return;
+ }
+ void handleToggleChatMessages(value);
+ }}
+ />
+ >
+ )}
);
diff --git a/apps/mobile/src/components/organization/credit-activity-screen.tsx b/apps/mobile/src/components/organization/credit-activity-screen.tsx
index 1c13be6ca2..f383cc7dc9 100644
--- a/apps/mobile/src/components/organization/credit-activity-screen.tsx
+++ b/apps/mobile/src/components/organization/credit-activity-screen.tsx
@@ -1,18 +1,22 @@
-import { fromMicrodollars } from '@kilocode/app-shared/utils';
+import { formatDollars, fromMicrodollars } from '@kilocode/app-shared/utils';
import { Receipt } from 'lucide-react-native';
+import { type ReactNode } from 'react';
import { FlatList, View } from 'react-native';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { EmptyState } from '@/components/empty-state';
+import { OrganizationBoundary } from '@/components/organization/organization-boundary';
+import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
+import { useTabBarBottomPadding } from '@/components/tab-screen';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
import {
type CreditTransaction,
+ useOrgBoundary,
useOrgCreditTransactions,
- useOrgRole,
} from '@/lib/hooks/use-organization-queries';
-import { cn, firstNonEmpty, parseTimestamp } from '@/lib/utils';
+import { cn, firstNonEmpty, formatDate, parseTimestamp } from '@/lib/utils';
function humanizeCategory(category: string): string {
const spaced = category.replaceAll('_', ' ');
@@ -44,16 +48,17 @@ function CreditRow({ transaction }: Readonly<{ transaction: CreditTransaction }>
{title}
- {isPositive ? '+' : '-'}${Math.abs(amount).toFixed(2)}
+ {isPositive ? '+' : '-'}
+ {formatDollars(Math.abs(amount))}
- {parseTimestamp(transaction.created_at).toLocaleDateString()}
+ {formatDate(parseTimestamp(transaction.created_at))}
{transaction.expiry_date != null && (
- Expires {parseTimestamp(transaction.expiry_date).toLocaleDateString()}
+ Expires {formatDate(parseTimestamp(transaction.expiry_date))}
)}
@@ -62,43 +67,58 @@ function CreditRow({ transaction }: Readonly<{ transaction: CreditTransaction }>
}
export function OrganizationCreditActivityScreen() {
- const { organizationId } = useOrgRole();
+ const { organizationId, org, isResolving } = useOrgBoundary();
const query = useOrgCreditTransactions(organizationId);
+ const paddingBottom = useTabBarBottomPadding();
- if (organizationId == null) {
- return null;
+ if (isResolving || organizationId == null || org == null) {
+ return ;
}
- const isLoading = query.isLoading || !query.data;
+ const isLoading = query.isLoading;
+ const isError = query.isError && !query.data;
const transactions = query.data ?? [];
+ let body: ReactNode = null;
+ if (isLoading) {
+ body = (
+
+
+
+
+
+ );
+ } else if (isError) {
+ body = (
+
+ void query.refetch()} isRetrying={query.isFetching} />
+
+ );
+ } else {
+ body = (
+
+ item.id}
+ renderItem={({ item }) => }
+ contentContainerClassName="grow gap-3 px-6 pt-4"
+ contentContainerStyle={{ paddingBottom }}
+ ListEmptyComponent={
+
+ }
+ />
+
+ );
+ }
+
return (
- {isLoading ? (
-
-
-
-
-
- ) : (
-
- item.id}
- renderItem={({ item }) => }
- contentContainerClassName="gap-3 px-6 pb-8 pt-4"
- ListEmptyComponent={
-
- }
- />
-
- )}
+ {body}
);
}
diff --git a/apps/mobile/src/components/organization/hub-screen.tsx b/apps/mobile/src/components/organization/hub-screen.tsx
index ec11fcf0cd..6eb7b2c9f9 100644
--- a/apps/mobile/src/components/organization/hub-screen.tsx
+++ b/apps/mobile/src/components/organization/hub-screen.tsx
@@ -1,86 +1,83 @@
-import { fromMicrodollars } from '@kilocode/app-shared/utils';
+import { formatDollars, fromMicrodollars } from '@kilocode/app-shared/utils';
import * as Haptics from 'expo-haptics';
import { type Href, useRouter } from 'expo-router';
import { Bell, FileText, Pencil, Receipt, Users } from 'lucide-react-native';
import { useState } from 'react';
-import { Pressable, ScrollView, View } from 'react-native';
-import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
+import { Pressable, View } from 'react-native';
+import Animated, { FadeIn } from 'react-native-reanimated';
+import { AddCreditsRow } from '@/components/add-credits-row';
+import { OrganizationBoundary } from '@/components/organization/organization-boundary';
import { OrgUsageStats } from '@/components/organization/org-usage-stats';
-import { RenameOrgModal } from '@/components/organization/rename-org-modal';
+import { RenameModal } from '@/components/rename-modal';
import { ScreenHeader } from '@/components/screen-header';
import { ConfigureRow } from '@/components/ui/configure-row';
import { KvRow } from '@/components/ui/kv-row';
-import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
+import { TabScreenScrollView } from '@/components/tab-screen';
+import { WEB_BASE_URL } from '@/lib/config';
import { useOrganizationMutations } from '@/lib/hooks/use-organization-mutations';
-import { isMoneyRole, useOrgRole, useOrgWithMembers } from '@/lib/hooks/use-organization-queries';
+import {
+ isMoneyRole,
+ useOrgBoundary,
+ useOrgWithMembers,
+} from '@/lib/hooks/use-organization-queries';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
-function InfoCardSkeleton() {
- return (
-
-
-
-
- );
-}
-
export function OrganizationHubScreen() {
const router = useRouter();
const colors = useThemeColors();
- const { organizationId, role, org, isLoading } = useOrgRole();
+ const { organizationId, role, org, isResolving } = useOrgBoundary();
const orgWithMembers = useOrgWithMembers(organizationId);
const mutations = useOrganizationMutations(organizationId ?? '');
const [renameVisible, setRenameVisible] = useState(false);
- if (organizationId == null) {
- return null;
+ if (isResolving || organizationId == null || org == null) {
+ return ;
}
const showMoney = isMoneyRole(role);
const minimumBalance = orgWithMembers.data?.settings.minimum_balance;
- const lowBalanceSubtitle = minimumBalance != null ? `Below $${minimumBalance.toFixed(2)}` : 'Off';
+ const lowBalanceSubtitle =
+ minimumBalance != null ? `Below ${formatDollars(minimumBalance)}` : 'Off';
return (
-
-
+
-
- {isLoading || !org ? (
-
-
-
- ) : (
-
-
-
- {org.organizationName}
-
- {showMoney && (
- {
- setRenameVisible(true);
- }}
- hitSlop={12}
- accessibilityRole="button"
- accessibilityLabel="Rename organization"
- className="active:opacity-70"
- >
-
-
- )}
-
- {showMoney && (
-
- )}
-
-
+
+
+
+ {org.organizationName}
+
+ {showMoney && (
+ {
+ setRenameVisible(true);
+ }}
+ hitSlop={12}
+ accessibilityRole="button"
+ accessibilityLabel="Rename organization"
+ className="active:opacity-70"
+ >
+
+
+ )}
+
+ {showMoney && (
+
+ )}
+ {showMoney && org.balance === 0 && (
+
)}
+
@@ -122,20 +119,16 @@ export function OrganizationHubScreen() {
>
)}
-
+
- {renameVisible && org && (
- {
- mutations.rename.mutate(
- { name },
- {
- onSuccess: () => {
- void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
- },
- }
- );
+ {renameVisible && (
+ {
+ await mutations.rename.mutateAsync({ name });
+ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}}
onClose={() => {
setRenameVisible(false);
diff --git a/apps/mobile/src/components/organization/invite-member-sheet.tsx b/apps/mobile/src/components/organization/invite-member-sheet.tsx
index b875d1a6c5..18bd0ce27e 100644
--- a/apps/mobile/src/components/organization/invite-member-sheet.tsx
+++ b/apps/mobile/src/components/organization/invite-member-sheet.tsx
@@ -2,34 +2,51 @@ import * as Haptics from 'expo-haptics';
import { useRouter } from 'expo-router';
import { Check } from 'lucide-react-native';
import { useRef, useState } from 'react';
-import { ActivityIndicator, Pressable, ScrollView, TextInput, View } from 'react-native';
+import { Pressable, ScrollView, View } from 'react-native';
import { ROLE_LABEL } from '@/components/organization/member-row';
+import { OrganizationBoundary } from '@/components/organization/organization-boundary';
+import { PermissionDenied } from '@/components/organization/permission-denied';
import { captureEvent, ORGANIZATION_MEMBER_INVITED_EVENT } from '@/lib/analytics/posthog';
import { Button } from '@/components/ui/button';
+import { FormField } from '@/components/ui/form-field';
+import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
import { useOrganizationMutations } from '@/lib/hooks/use-organization-mutations';
-import { type OrgRole, useOrgRole } from '@/lib/hooks/use-organization-queries';
+import { isMoneyRole, type OrgRole, useOrgBoundary } from '@/lib/hooks/use-organization-queries';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { cn, EMAIL_PATTERN } from '@/lib/utils';
const INVITABLE_ROLES: OrgRole[] = ['member', 'billing_manager', 'owner'];
+const EMAIL_ERROR = 'Enter a valid email address';
export function InviteMemberSheet() {
const router = useRouter();
const colors = useThemeColors();
- const { organizationId, role: myRole } = useOrgRole();
+ const { organizationId, role: myRole, org, isResolving } = useOrgBoundary();
const mutations = useOrganizationMutations(organizationId ?? '');
const emailRef = useRef('');
const [canSubmit, setCanSubmit] = useState(false);
const isBillingManager = myRole === 'billing_manager';
const [role, setRole] = useState('member');
+ if (isResolving) {
+ return (
+
+
+
+
+ );
+ }
+ if (organizationId == null || org == null) {
+ return ;
+ }
+ if (!isMoneyRole(myRole)) {
+ return ;
+ }
+
const onSubmit = () => {
const email = emailRef.current.trim().toLowerCase();
- if (!EMAIL_PATTERN.test(email)) {
- return;
- }
mutations.invite.mutate(
{ email, role: isBillingManager ? 'member' : role },
{
@@ -53,25 +70,20 @@ export function InviteMemberSheet() {
>
Invite member
-
-
- Email
-
- {
- emailRef.current = value;
- setCanSubmit(EMAIL_PATTERN.test(value.trim()));
- }}
- />
-
+ (EMAIL_PATTERN.test(value.trim()) ? null : EMAIL_ERROR)}
+ onChangeText={value => {
+ emailRef.current = value;
+ setCanSubmit(EMAIL_PATTERN.test(value.trim()));
+ }}
+ />
{isBillingManager ? (
Role: Member
@@ -109,10 +121,7 @@ export function InviteMemberSheet() {
{mutations.invite.error.message}
)}
-
- {mutations.invite.isPending ? (
-
- ) : null}
+
Send invite
diff --git a/apps/mobile/src/components/organization/invited-member-row.tsx b/apps/mobile/src/components/organization/invited-member-row.tsx
index 0247858f0a..b1c79fb736 100644
--- a/apps/mobile/src/components/organization/invited-member-row.tsx
+++ b/apps/mobile/src/components/organization/invited-member-row.tsx
@@ -5,7 +5,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text } from '@/components/ui/text';
import { useOrganizationMutations } from '@/lib/hooks/use-organization-mutations';
import { type InvitedOrgMember } from '@/lib/hooks/use-organization-queries';
-import { cn, parseTimestamp } from '@/lib/utils';
+import { cn, formatDate, parseTimestamp } from '@/lib/utils';
import { ROLE_LABEL } from './member-row';
@@ -22,7 +22,7 @@ function inviteDateLabel(inviteDate: string | null): string | null {
if (inviteDate == null) {
return null;
}
- return `Invited ${parseTimestamp(inviteDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`;
+ return `Invited ${formatDate(parseTimestamp(inviteDate))}`;
}
export function InvitedMemberRow({
diff --git a/apps/mobile/src/components/organization/invoices-screen.tsx b/apps/mobile/src/components/organization/invoices-screen.tsx
index 3c31a81d77..1cdafcb218 100644
--- a/apps/mobile/src/components/organization/invoices-screen.tsx
+++ b/apps/mobile/src/components/organization/invoices-screen.tsx
@@ -1,13 +1,22 @@
+import { formatCents } from '@kilocode/app-shared/utils';
import { FileText } from 'lucide-react-native';
+import { type ReactNode } from 'react';
import { FlatList, View } from 'react-native';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { EmptyState } from '@/components/empty-state';
+import { OrganizationBoundary } from '@/components/organization/organization-boundary';
+import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
+import { useTabBarBottomPadding } from '@/components/tab-screen';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
-import { type OrgInvoice, useOrgInvoices, useOrgRole } from '@/lib/hooks/use-organization-queries';
-import { cn, firstNonEmpty } from '@/lib/utils';
+import {
+ type OrgInvoice,
+ useOrgBoundary,
+ useOrgInvoices,
+} from '@/lib/hooks/use-organization-queries';
+import { cn, firstNonEmpty, formatDate } from '@/lib/utils';
const STATUS_META: Record = {
paid: { label: 'Paid', pillClass: 'bg-good', textClass: 'text-good-foreground' },
@@ -45,12 +54,12 @@ function InvoiceRow({ invoice }: Readonly<{ invoice: OrgInvoice }>) {
{title}
- ${(invoice.amount_due / 100).toFixed(2)}
+ {formatCents(invoice.amount_due)}
- {new Date(invoice.created * 1000).toLocaleDateString()}
+ {formatDate(new Date(invoice.created * 1000))}
{meta.label}
@@ -61,43 +70,58 @@ function InvoiceRow({ invoice }: Readonly<{ invoice: OrgInvoice }>) {
}
export function OrganizationInvoicesScreen() {
- const { organizationId } = useOrgRole();
+ const { organizationId, org, isResolving } = useOrgBoundary();
const query = useOrgInvoices(organizationId);
+ const paddingBottom = useTabBarBottomPadding();
- if (organizationId == null) {
- return null;
+ if (isResolving || organizationId == null || org == null) {
+ return ;
}
- const isLoading = query.isLoading || !query.data;
+ const isLoading = query.isLoading;
+ const isError = query.isError && !query.data;
const invoices = query.data ?? [];
+ let body: ReactNode = null;
+ if (isLoading) {
+ body = (
+
+
+
+
+
+ );
+ } else if (isError) {
+ body = (
+
+ void query.refetch()} isRetrying={query.isFetching} />
+
+ );
+ } else {
+ body = (
+
+ item.id}
+ renderItem={({ item }) => }
+ contentContainerClassName="grow gap-3 px-6 pt-4"
+ contentContainerStyle={{ paddingBottom }}
+ ListEmptyComponent={
+
+ }
+ />
+
+ );
+ }
+
return (
- {isLoading ? (
-
-
-
-
-
- ) : (
-
- item.id}
- renderItem={({ item }) => }
- contentContainerClassName="gap-3 px-6 pb-8 pt-4"
- ListEmptyComponent={
-
- }
- />
-
- )}
+ {body}
);
}
diff --git a/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx b/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx
index ee39022f68..38ffea7efe 100644
--- a/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx
+++ b/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx
@@ -1,36 +1,29 @@
import * as Haptics from 'expo-haptics';
import { useRouter } from 'expo-router';
-import { useRef, useState } from 'react';
-import { ActivityIndicator, ScrollView, Switch, TextInput, View } from 'react-native';
+import { type ReactNode, useRef, useState } from 'react';
+import { ScrollView, Switch, View } from 'react-native';
+import { OrganizationBoundary } from '@/components/organization/organization-boundary';
+import {
+ emailsError,
+ parseEmails,
+ parseThreshold,
+ thresholdError,
+} from '@/components/organization/low-balance-alert-validators';
+import { PermissionDenied } from '@/components/organization/permission-denied';
+import { QueryError } from '@/components/query-error';
import { Button } from '@/components/ui/button';
+import { FormField } from '@/components/ui/form-field';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
import { useCurrentUserId } from '@/lib/hooks/use-current-user-id';
import { useOrganizationMutations } from '@/lib/hooks/use-organization-mutations';
import {
+ isMoneyRole,
type OrgWithMembers,
- useOrgRole,
+ useOrgBoundary,
useOrgWithMembers,
} from '@/lib/hooks/use-organization-queries';
-import { useThemeColors } from '@/lib/hooks/use-theme-colors';
-import { EMAIL_PATTERN } from '@/lib/utils';
-
-function parseThreshold(value: string): number | null {
- const trimmed = value.trim();
- const parsed = Number(trimmed);
- if (trimmed === '' || !Number.isFinite(parsed) || parsed <= 0) {
- return null;
- }
- return parsed;
-}
-
-function parseEmails(value: string): string[] {
- return value
- .split(',')
- .map(email => email.trim())
- .filter(email => email !== '');
-}
type LowBalanceAlertFormProps = Readonly<{
organizationId: string | null;
@@ -39,7 +32,6 @@ type LowBalanceAlertFormProps = Readonly<{
function LowBalanceAlertForm({ organizationId, settings }: LowBalanceAlertFormProps) {
const router = useRouter();
- const colors = useThemeColors();
const mutations = useOrganizationMutations(organizationId ?? '');
const { email: myEmail } = useCurrentUserId();
@@ -47,24 +39,21 @@ function LowBalanceAlertForm({ organizationId, settings }: LowBalanceAlertFormPr
const thresholdRef = useRef(
settings.minimum_balance != null ? String(settings.minimum_balance) : ''
);
- const emailsRef = useRef((settings.minimum_balance_alert_email ?? []).join(', '));
- const [canSave, setCanSave] = useState(() => {
- const emails = parseEmails(emailsRef.current);
- return (
+ // Default to the signer's own email when no alert email is stored yet, so
+ // the field starts pre-filled with a real, savable value rather than a
+ // placeholder that looks filled in but saves as empty.
+ const emailsRef = useRef(
+ (settings.minimum_balance_alert_email ?? (myEmail ? [myEmail] : [])).join(', ')
+ );
+ const [canSave, setCanSave] = useState(
+ () =>
!enabled ||
- (parseThreshold(thresholdRef.current) != null &&
- emails.length > 0 &&
- emails.every(email => EMAIL_PATTERN.test(email)))
- );
- });
+ (thresholdError(thresholdRef.current) == null && emailsError(emailsRef.current) == null)
+ );
const revalidate = (nextEnabled: boolean, thresholdValue: string, emailsValue: string) => {
- const emails = parseEmails(emailsValue);
setCanSave(
- !nextEnabled ||
- (parseThreshold(thresholdValue) != null &&
- emails.length > 0 &&
- emails.every(email => EMAIL_PATTERN.test(email)))
+ !nextEnabled || (thresholdError(thresholdValue) == null && emailsError(emailsValue) == null)
);
};
@@ -107,37 +96,29 @@ function LowBalanceAlertForm({ organizationId, settings }: LowBalanceAlertFormPr
{enabled && (
<>
-
-
- Alert below (USD)
-
- {
- thresholdRef.current = value;
- revalidate(enabled, value, emailsRef.current);
- }}
- />
-
-
-
-
- Notify emails
-
- {
+ thresholdRef.current = value;
+ revalidate(enabled, value, emailsRef.current);
+ }}
+ />
+
+
+ {
emailsRef.current = value;
revalidate(enabled, thresholdRef.current, value);
@@ -156,10 +137,11 @@ function LowBalanceAlertForm({ organizationId, settings }: LowBalanceAlertFormPr
)}
-
- {mutations.updateMinimumBalanceAlert.isPending ? (
-
- ) : null}
+
Save
>
@@ -167,9 +149,55 @@ function LowBalanceAlertForm({ organizationId, settings }: LowBalanceAlertFormPr
}
export function LowBalanceAlertSheet() {
- const { organizationId } = useOrgRole();
+ const { organizationId, role, org, isResolving } = useOrgBoundary();
const orgWithMembers = useOrgWithMembers(organizationId);
+ // isPending (no data AND no error) rather than isLoading: an offline
+ // paused fetch has isLoading false but no data — it must show the skeleton
+ // instead of rendering nothing, while a real error still reaches QueryError.
+ // The organizationId guard keeps a disabled query (null org, isPending
+ // forever) falling through to OrganizationBoundary below.
+ if (isResolving || (organizationId != null && orgWithMembers.isPending)) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+ if (organizationId == null || org == null) {
+ return ;
+ }
+ if (!isMoneyRole(role)) {
+ return ;
+ }
+
+ let body: ReactNode = null;
+ if (orgWithMembers.data) {
+ body = (
+
+ );
+ } else if (orgWithMembers.isError) {
+ body = (
+ void orgWithMembers.refetch()}
+ isRetrying={orgWithMembers.isFetching}
+ placement="top"
+ />
+ );
+ }
+
return (
Low balance alert
- {orgWithMembers.data ? (
-
- ) : (
- <>
-
-
- >
- )}
+ {body}
);
}
diff --git a/apps/mobile/src/components/organization/low-balance-alert-validators.test.ts b/apps/mobile/src/components/organization/low-balance-alert-validators.test.ts
new file mode 100644
index 0000000000..f9acce80e1
--- /dev/null
+++ b/apps/mobile/src/components/organization/low-balance-alert-validators.test.ts
@@ -0,0 +1,9 @@
+import { describe, expect, it } from 'vitest';
+
+import { emailsError } from '@/components/organization/low-balance-alert-validators';
+
+describe('emailsError', () => {
+ it('fails the whole list when any email is malformed', () => {
+ expect(emailsError('a@x.com, not-an-email')).not.toBeNull();
+ });
+});
diff --git a/apps/mobile/src/components/organization/low-balance-alert-validators.ts b/apps/mobile/src/components/organization/low-balance-alert-validators.ts
new file mode 100644
index 0000000000..390e974f59
--- /dev/null
+++ b/apps/mobile/src/components/organization/low-balance-alert-validators.ts
@@ -0,0 +1,31 @@
+import { EMAIL_PATTERN } from '@/lib/utils';
+
+const THRESHOLD_ERROR = 'Enter an amount greater than 0';
+const EMAILS_ERROR = 'Enter at least one valid email, separated by commas';
+
+export function parseThreshold(value: string): number | null {
+ const trimmed = value.trim();
+ const parsed = Number(trimmed);
+ if (trimmed === '' || !Number.isFinite(parsed) || parsed <= 0) {
+ return null;
+ }
+ return parsed;
+}
+
+export function parseEmails(value: string): string[] {
+ return value
+ .split(',')
+ .map(email => email.trim())
+ .filter(email => email !== '');
+}
+
+export function thresholdError(value: string): string | null {
+ return parseThreshold(value) == null ? THRESHOLD_ERROR : null;
+}
+
+export function emailsError(value: string): string | null {
+ const emails = parseEmails(value);
+ return emails.length === 0 || !emails.every(email => EMAIL_PATTERN.test(email))
+ ? EMAILS_ERROR
+ : null;
+}
diff --git a/apps/mobile/src/components/organization/member-limit-sheet.tsx b/apps/mobile/src/components/organization/member-limit-sheet.tsx
index 4276bf8853..8f0263158b 100644
--- a/apps/mobile/src/components/organization/member-limit-sheet.tsx
+++ b/apps/mobile/src/components/organization/member-limit-sheet.tsx
@@ -1,38 +1,24 @@
import * as Haptics from 'expo-haptics';
import { useRouter } from 'expo-router';
import { useRef, useState } from 'react';
-import { ActivityIndicator, ScrollView, TextInput, View } from 'react-native';
+import { ScrollView, View } from 'react-native';
+import { OrganizationBoundary } from '@/components/organization/organization-boundary';
+import { limitError, parseLimit } from '@/components/organization/member-limit-validators';
+import { PermissionDenied } from '@/components/organization/permission-denied';
+import { QueryError } from '@/components/query-error';
import { Button } from '@/components/ui/button';
+import { FormField } from '@/components/ui/form-field';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
import { useOrganizationMutations } from '@/lib/hooks/use-organization-mutations';
import {
type ActiveOrgMember,
- useOrgRole,
+ useOrgBoundary,
useOrgWithMembers,
} from '@/lib/hooks/use-organization-queries';
-import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { firstNonEmpty } from '@/lib/utils';
-const MAX_DAILY_LIMIT_USD = 2000;
-
-function parseLimit(value: string): number | null {
- const trimmed = value.trim();
- if (trimmed === '') {
- return null;
- }
- const parsed = Number(trimmed);
- if (!Number.isFinite(parsed) || parsed < 0 || parsed > MAX_DAILY_LIMIT_USD) {
- return null;
- }
- return parsed;
-}
-
-function isValidLimit(value: string): boolean {
- return parseLimit(value) != null;
-}
-
type MemberLimitFormProps = Readonly<{
memberId: string;
organizationId: string | null;
@@ -41,12 +27,13 @@ type MemberLimitFormProps = Readonly<{
function MemberLimitForm({ memberId, organizationId, member }: MemberLimitFormProps) {
const router = useRouter();
- const colors = useThemeColors();
- const mutations = useOrganizationMutations(organizationId ?? '');
+ const mutations = useOrganizationMutations(organizationId ?? '', {
+ silenceUpdateMemberToast: true,
+ });
const currentLimit = member.dailyUsageLimitUsd;
const limitRef = useRef(currentLimit != null ? String(currentLimit) : '');
- const [canSave, setCanSave] = useState(isValidLimit(limitRef.current));
+ const [canSave, setCanSave] = useState(limitError(limitRef.current) == null);
const onSaved = () => {
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
@@ -54,10 +41,10 @@ function MemberLimitForm({ memberId, organizationId, member }: MemberLimitFormPr
};
const onSave = () => {
- const parsed = parseLimit(limitRef.current);
- if (parsed == null) {
+ if (!canSave) {
return;
}
+ const parsed = parseLimit(limitRef.current);
mutations.updateMember.mutate({ memberId, dailyUsageLimitUsd: parsed }, { onSuccess: onSaved });
};
@@ -67,32 +54,24 @@ function MemberLimitForm({ memberId, organizationId, member }: MemberLimitFormPr
return (
<>
-
-
- Limit (USD per day)
-
- {
- limitRef.current = value;
- setCanSave(isValidLimit(value));
- }}
- />
-
+ {
+ limitRef.current = value;
+ setCanSave(limitError(value) == null);
+ }}
+ />
{mutations.updateMember.isError && (
{mutations.updateMember.error.message}
)}
-
- {mutations.updateMember.isPending ? (
-
- ) : null}
+
Save
@@ -110,13 +89,13 @@ function MemberLimitForm({ memberId, organizationId, member }: MemberLimitFormPr
}
export function MemberLimitSheet({ memberId }: Readonly<{ memberId: string }>) {
- const { organizationId } = useOrgRole();
+ const { organizationId, role, org, isResolving } = useOrgBoundary();
const orgWithMembers = useOrgWithMembers(organizationId);
const member = orgWithMembers.data?.members.find(
(m): m is ActiveOrgMember => m.status === 'active' && m.id === memberId
);
- if (orgWithMembers.isLoading) {
+ if (isResolving || orgWithMembers.isLoading) {
return (
@@ -129,6 +108,27 @@ export function MemberLimitSheet({ memberId }: Readonly<{ memberId: string }>) {
);
}
+ if (organizationId == null || org == null) {
+ return ;
+ }
+
+ if (role !== 'owner') {
+ return ;
+ }
+
+ if (orgWithMembers.isError && !orgWithMembers.data) {
+ return (
+
+ Daily usage limit
+ void orgWithMembers.refetch()}
+ isRetrying={orgWithMembers.isFetching}
+ placement="top"
+ />
+
+ );
+ }
+
if (!member) {
return (
diff --git a/apps/mobile/src/components/organization/member-limit-validators.test.ts b/apps/mobile/src/components/organization/member-limit-validators.test.ts
new file mode 100644
index 0000000000..aa46657975
--- /dev/null
+++ b/apps/mobile/src/components/organization/member-limit-validators.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from 'vitest';
+
+import { limitError } from '@/components/organization/member-limit-validators';
+
+describe('limitError', () => {
+ it('disables save on a blank field instead of treating it as "remove"', () => {
+ expect(limitError('')).not.toBeNull();
+ expect(limitError(' ')).not.toBeNull();
+ });
+});
diff --git a/apps/mobile/src/components/organization/member-limit-validators.ts b/apps/mobile/src/components/organization/member-limit-validators.ts
new file mode 100644
index 0000000000..a01b045833
--- /dev/null
+++ b/apps/mobile/src/components/organization/member-limit-validators.ts
@@ -0,0 +1,23 @@
+const MAX_DAILY_LIMIT_USD = 2000;
+const LIMIT_RANGE_ERROR = `Enter an amount between 0 and ${MAX_DAILY_LIMIT_USD}`;
+const LIMIT_BLANK_ERROR = 'Enter an amount, or use Remove limit below';
+
+// A blank field disables Save — it does NOT remove the limit. Removal only
+// happens via the explicit "Remove limit" button, so clearing the input by
+// mistake can never silently drop a configured money limit on Save.
+export function limitError(value: string): string | null {
+ const trimmed = value.trim();
+ if (trimmed === '') {
+ return LIMIT_BLANK_ERROR;
+ }
+ const parsed = Number(trimmed);
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > MAX_DAILY_LIMIT_USD) {
+ return LIMIT_RANGE_ERROR;
+ }
+ return null;
+}
+
+export function parseLimit(value: string): number | null {
+ const trimmed = value.trim();
+ return trimmed === '' ? null : Number(trimmed);
+}
diff --git a/apps/mobile/src/components/organization/member-row.tsx b/apps/mobile/src/components/organization/member-row.tsx
index afe92935fd..2a1fb449b0 100644
--- a/apps/mobile/src/components/organization/member-row.tsx
+++ b/apps/mobile/src/components/organization/member-row.tsx
@@ -1,4 +1,5 @@
import { useActionSheet } from '@expo/react-native-action-sheet';
+import { formatDollars } from '@kilocode/app-shared/utils';
import * as Haptics from 'expo-haptics';
import { type Href, useRouter } from 'expo-router';
import { Alert, Pressable, View } from 'react-native';
@@ -137,7 +138,7 @@ export function MemberRow({
{member.dailyUsageLimitUsd != null && (
- ${member.dailyUsageLimitUsd.toFixed(2)}/day
+ {formatDollars(member.dailyUsageLimitUsd)}/day
)}
diff --git a/apps/mobile/src/components/organization/members-screen.tsx b/apps/mobile/src/components/organization/members-screen.tsx
index 987cee4b17..2bf6ba591b 100644
--- a/apps/mobile/src/components/organization/members-screen.tsx
+++ b/apps/mobile/src/components/organization/members-screen.tsx
@@ -1,19 +1,25 @@
import { type Href, useRouter } from 'expo-router';
-import { UserPlus } from 'lucide-react-native';
-import { Pressable, ScrollView, View } from 'react-native';
+import { UserPlus, Users } from 'lucide-react-native';
+import { type ReactNode } from 'react';
+import { Pressable, View } from 'react-native';
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
+import { EmptyState } from '@/components/empty-state';
import { InvitedMemberRow } from '@/components/organization/invited-member-row';
import { MemberRow } from '@/components/organization/member-row';
+import { OrganizationBoundary } from '@/components/organization/organization-boundary';
+import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
+import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
+import { TabScreenScrollView } from '@/components/tab-screen';
import { useCurrentUserId } from '@/lib/hooks/use-current-user-id';
import {
type ActiveOrgMember,
type InvitedOrgMember,
isMoneyRole,
- useOrgRole,
+ useOrgBoundary,
useOrgWithMembers,
} from '@/lib/hooks/use-organization-queries';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
@@ -53,15 +59,16 @@ function MemberRowSkeleton({ last }: Readonly<{ last?: boolean }>) {
export function OrganizationMembersScreen() {
const router = useRouter();
const colors = useThemeColors();
- const { organizationId, role } = useOrgRole();
+ const { organizationId, role, org, isResolving } = useOrgBoundary();
const orgWithMembers = useOrgWithMembers(organizationId);
const { userId: currentUserId } = useCurrentUserId();
- if (organizationId == null) {
- return null;
+ if (isResolving || organizationId == null || org == null) {
+ return ;
}
- const isLoading = orgWithMembers.isLoading || !orgWithMembers.data;
+ const isLoading = orgWithMembers.isLoading;
+ const isError = orgWithMembers.isError && !orgWithMembers.data;
const enableUsageLimits = orgWithMembers.data?.settings.enable_usage_limits !== false;
const canInvite = isMoneyRole(role);
const isOwner = role === 'owner';
@@ -73,6 +80,64 @@ export function OrganizationMembersScreen() {
orgWithMembers.data?.members.filter(m => m.status === 'invited') ?? []
);
+ let membersBody: ReactNode = null;
+ if (isLoading) {
+ membersBody = (
+
+
+
+
+
+ );
+ } else if (isError) {
+ membersBody = (
+ void orgWithMembers.refetch()}
+ isRetrying={orgWithMembers.isFetching}
+ placement="top"
+ />
+ );
+ } else if (activeMembers.length === 0) {
+ membersBody = (
+ {
+ router.push('/(app)/(tabs)/(3_profile)/organization/invite-member' as Href);
+ }}
+ >
+ Invite member
+
+ ) : undefined
+ }
+ />
+ );
+ } else {
+ membersBody = (
+
+ {activeMembers.map((member, index) => (
+
+ ))}
+
+ );
+ }
+
return (
-
Members
- {isLoading ? (
-
-
-
-
-
- ) : (
-
- {activeMembers.map((member, index) => (
-
- ))}
-
- )}
+ {membersBody}
- {!isLoading && invitedMembers.length > 0 && (
+ {!isLoading && !isError && invitedMembers.length > 0 && (
)}
-
+
);
}
diff --git a/apps/mobile/src/components/organization/org-usage-stats.tsx b/apps/mobile/src/components/organization/org-usage-stats.tsx
index 83cae241d2..c58599d415 100644
--- a/apps/mobile/src/components/organization/org-usage-stats.tsx
+++ b/apps/mobile/src/components/organization/org-usage-stats.tsx
@@ -1,4 +1,5 @@
-import { fromMicrodollars } from '@kilocode/app-shared/utils';
+import { formatDollars, fromMicrodollars } from '@kilocode/app-shared/utils';
+import { type ReactNode } from 'react';
import { View } from 'react-native';
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
@@ -30,36 +31,50 @@ type OrgUsageStatsProps = {
/** "Last 30 days" eyebrow + 2x2 usage stat tile grid. Visible to all org roles. */
export function OrgUsageStats({ organizationId }: Readonly) {
- const { data, isLoading } = useOrgUsageStats(organizationId);
+ const { data, isLoading, isError } = useOrgUsageStats(organizationId);
+
+ // An embedded stat block has no room for a retry affordance — hide the
+ // section on a hard failure instead of showing a full QueryError. Stale
+ // data from a prior successful load stays visible through a refetch error.
+ if (isError && !data) {
+ return null;
+ }
+
+ let body: ReactNode = null;
+ if (isLoading) {
+ body = (
+
+
+
+
+
+
+
+
+
+
+ );
+ } else if (data) {
+ body = (
+
+
+
+
+
+
+
+
+
+
+ );
+ }
return (
Last 30 days
- {isLoading || !data ? (
-
-
-
-
-
-
-
-
-
-
- ) : (
-
-
-
-
-
-
-
-
-
-
- )}
+ {body}
);
}
diff --git a/apps/mobile/src/components/organization/organization-boundary.tsx b/apps/mobile/src/components/organization/organization-boundary.tsx
new file mode 100644
index 0000000000..c001186c07
--- /dev/null
+++ b/apps/mobile/src/components/organization/organization-boundary.tsx
@@ -0,0 +1,87 @@
+import { type Href, useRouter } from 'expo-router';
+import { Building2 } from 'lucide-react-native';
+import { type ReactNode } from 'react';
+import { ActivityIndicator, View } from 'react-native';
+
+import { EmptyState } from '@/components/empty-state';
+import { QueryError } from '@/components/query-error';
+import { ScreenHeader } from '@/components/screen-header';
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { useOrgBoundary } from '@/lib/hooks/use-organization-queries';
+
+const PROFILE_HREF = '/(app)/(tabs)/(3_profile)' as Href;
+
+type OrganizationBoundaryProps = Readonly<{
+ /** Full-screen callers pass a title for the `ScreenHeader`; sheets omit it and own no chrome. */
+ title?: string;
+}>;
+
+/**
+ * Content shown in place of an organization screen or sheet when the org
+ * context isn't ready to render the real content — never renders `null`, so
+ * the route is never blank. Three distinct cases, in priority order:
+ * 1. `isResolving` — identity/org-list still loading, brief spinner.
+ * 2. `isError` — `organizations.list` itself failed to fetch; this is
+ * retryable and must NOT be conflated with a stale org selection.
+ * 3. otherwise — the list loaded fine but the persisted `organizationId`
+ * doesn't resolve to a membership: either nothing was ever selected, or
+ * the selected org is stale (deleted / user removed). Each gets its own
+ * copy.
+ * Calls `useOrgBoundary()` itself — cheap, context/query-cache backed — so
+ * callers only need to pass a `title` for full screens (sheets omit it).
+ */
+export function OrganizationBoundary({ title }: OrganizationBoundaryProps = {}) {
+ const router = useRouter();
+ const colors = useThemeColors();
+ const { organizationId, isResolving, isError, isFetching, refetch } = useOrgBoundary();
+
+ let content: ReactNode = null;
+ if (isResolving) {
+ content = (
+
+
+
+ );
+ } else if (isError) {
+ content = (
+ void refetch()}
+ isRetrying={isFetching}
+ />
+ );
+ } else {
+ const noSelection = organizationId == null;
+ content = (
+ {
+ router.replace(PROFILE_HREF);
+ }}
+ >
+ Back to profile
+
+ }
+ />
+ );
+ }
+
+ return (
+
+ {title != null && }
+ {content}
+
+ );
+}
diff --git a/apps/mobile/src/components/organization/permission-denied.tsx b/apps/mobile/src/components/organization/permission-denied.tsx
new file mode 100644
index 0000000000..d8f7b0f911
--- /dev/null
+++ b/apps/mobile/src/components/organization/permission-denied.tsx
@@ -0,0 +1,40 @@
+import { useRouter } from 'expo-router';
+import { Lock } from 'lucide-react-native';
+import { View } from 'react-native';
+
+import { EmptyState } from '@/components/empty-state';
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+
+type PermissionDeniedProps = Readonly<{
+ description: string;
+}>;
+
+/**
+ * Shown in place of a privileged form when the signed-in role can't perform
+ * the action — e.g. reached by a deep link or stale state rather than
+ * through the (role-gated) entry point that would normally hide it.
+ */
+export function PermissionDenied({ description }: PermissionDeniedProps) {
+ const router = useRouter();
+
+ return (
+
+ {
+ router.back();
+ }}
+ >
+ Back
+
+ }
+ />
+
+ );
+}
diff --git a/apps/mobile/src/components/organization/rename-org-modal.tsx b/apps/mobile/src/components/organization/rename-org-modal.tsx
deleted file mode 100644
index 51842a46a1..0000000000
--- a/apps/mobile/src/components/organization/rename-org-modal.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import { useEffect, useRef } from 'react';
-import { Modal, Platform, Pressable, TextInput, View } from 'react-native';
-
-import { Button } from '@/components/ui/button';
-import { Text } from '@/components/ui/text';
-import { useThemeColors } from '@/lib/hooks/use-theme-colors';
-
-type RenameOrgModalProps = {
- defaultName: string;
- onSubmit: (name: string) => void;
- onClose: () => void;
-};
-
-export function RenameOrgModal({ defaultName, onSubmit, onClose }: Readonly) {
- const colors = useThemeColors();
- const nameRef = useRef(defaultName);
- const inputRef = useRef(null);
-
- // autoFocus doesn't reliably raise the keyboard inside Modal on Android
- useEffect(() => {
- if (Platform.OS !== 'android') {
- return undefined;
- }
- const timer = setTimeout(() => {
- inputRef.current?.focus();
- }, 100);
- return () => {
- clearTimeout(timer);
- };
- }, []);
-
- return (
-
-
-
- {
- e.stopPropagation();
- }}
- >
- Rename Organization
- {
- nameRef.current = val;
- }}
- autoFocus={Platform.OS !== 'android'}
- maxLength={50}
- />
-
-
- Cancel
-
- {
- const trimmed = nameRef.current.trim();
- if (trimmed) {
- onSubmit(trimmed);
- }
- onClose();
- }}
- >
- Save
-
-
-
-
-
- );
-}
diff --git a/apps/mobile/src/components/picker-sheet.tsx b/apps/mobile/src/components/picker-sheet.tsx
new file mode 100644
index 0000000000..b50c2b0243
--- /dev/null
+++ b/apps/mobile/src/components/picker-sheet.tsx
@@ -0,0 +1,53 @@
+import { Info } from 'lucide-react-native';
+import { type ReactNode } from 'react';
+import { ScrollView } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { EmptyState } from '@/components/empty-state';
+import { SheetHeader } from '@/components/sheet-header';
+
+export function PickerSheet({
+ title,
+ onDone,
+ children,
+ expired = false,
+ scrollable = true,
+}: {
+ title: string;
+ onDone: () => void;
+ children?: ReactNode;
+ /** Set when the caller's data source (picker bridge) is gone — renders the standard "Options expired" empty state instead of children. */
+ expired?: boolean;
+ /**
+ * Set to false when children manage their own scrolling (e.g. a FlatList
+ * with search-as-you-type rows) — the shell then just renders them below
+ * the header instead of nesting them in a ScrollView.
+ */
+ scrollable?: boolean;
+}) {
+ const { bottom } = useSafeAreaInsets();
+ const body = expired ? (
+
+ ) : (
+ children
+ );
+
+ // No wrapping View: react-native-screens sizes a formSheet's scroll view
+ // natively and only honors a header when [header, scroll view] are the
+ // screen content's direct children. An extra wrapper makes it fall back to
+ // pinning the scroll view to the full sheet, painting it over the header.
+ return (
+ <>
+
+ {scrollable ? (
+ {body}
+ ) : (
+ body
+ )}
+ >
+ );
+}
diff --git a/apps/mobile/src/components/platform-error-screen.tsx b/apps/mobile/src/components/platform-error-screen.tsx
new file mode 100644
index 0000000000..02aefebe46
--- /dev/null
+++ b/apps/mobile/src/components/platform-error-screen.tsx
@@ -0,0 +1,48 @@
+import { View } from 'react-native';
+
+import { QueryError, type QueryErrorVariant } from '@/components/query-error';
+import { ScreenHeader } from '@/components/screen-header';
+import { useTabBarBottomPadding } from '@/components/tab-screen';
+
+/**
+ * Full-screen "load failed" state: a ScreenHeader over a centered QueryError,
+ * padded above the tab bar. Shared by code-reviewer's platform overview
+ * screens and security-agent's settings/dashboard screens — both render
+ * exactly this shape when their top-level query errors out with no cached
+ * data to fall back on.
+ */
+export function PlatformErrorScreen({
+ title,
+ eyebrow,
+ errorTitle,
+ message,
+ variant = 'server',
+ onRetry,
+ isRetrying = false,
+}: Readonly<{
+ /** ScreenHeader's title, e.g. the screen/feature name. */
+ title: string;
+ eyebrow?: string;
+ /** QueryError's own title override — independent of the ScreenHeader title above. */
+ errorTitle?: string;
+ message?: string;
+ variant?: QueryErrorVariant;
+ onRetry: () => void;
+ isRetrying?: boolean;
+}>) {
+ const paddingBottom = useTabBarBottomPadding();
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/profile-credits-card.tsx b/apps/mobile/src/components/profile-credits-card.tsx
index 0657f8d576..94aaeb7f3a 100644
--- a/apps/mobile/src/components/profile-credits-card.tsx
+++ b/apps/mobile/src/components/profile-credits-card.tsx
@@ -1,21 +1,25 @@
import { useActionSheet } from '@expo/react-native-action-sheet';
+import { formatDollars, fromMicrodollars } from '@kilocode/app-shared/utils';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { ChevronDown } from 'lucide-react-native';
-import { ActivityIndicator, Pressable, View } from 'react-native';
+import { ActivityIndicator, Platform, Pressable, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
+import { AddCreditsRow } from '@/components/add-credits-row';
import { KiloPassSubscriptionCard } from '@/components/kilo-pass/kilo-pass-subscription-card';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
+import { WEB_BASE_URL } from '@/lib/config';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { isMoneyRole, type OrgListEntry } from '@/lib/hooks/use-organization-queries';
import { useOrganization } from '@/lib/organization-context';
import { useTRPC } from '@/lib/trpc';
-import { parseTimestamp } from '@/lib/utils';
+import { formatDate, parseTimestamp } from '@/lib/utils';
type CreditsCardProps = {
readonly enabled: boolean;
- orgs: { organizationId: string; organizationName: string }[] | undefined;
+ orgs: OrgListEntry[] | undefined;
};
export function CreditsCard({ enabled, orgs }: Readonly) {
@@ -54,7 +58,9 @@ export function CreditsCard({ enabled, orgs }: Readonly) {
const balanceDollars = balance?.balance ?? 0;
const expiringBlocks = creditData?.creditBlocks.filter(b => b.expiry_date !== null) ?? [];
- const expiringTotal = expiringBlocks.reduce((sum, b) => sum + b.balance_mUsd, 0) / 1_000_000;
+ const expiringTotal = fromMicrodollars(
+ expiringBlocks.reduce((sum, b) => sum + b.balance_mUsd, 0)
+ );
const earliestExpiry = expiringBlocks
.map(b => b.expiry_date)
.filter((d): d is string => d !== null)
@@ -67,6 +73,18 @@ export function CreditsCard({ enabled, orgs }: Readonly) {
const hasOrgs = orgs && orgs.length > 0;
+ // Personal (non-org) credits are a consumable purchased directly by the end
+ // user, so Apple requires IAP for them — iOS can't show purchase language
+ // pointing at the web billing page. Org billing is exempt (business/seat
+ // billing), but only members who can manage billing should see the CTA —
+ // matching the money-role gate on the organization hub screen.
+ const selectedOrgRole = orgs?.find(o => o.organizationId === selectedOrgId)?.role;
+ const canShowZeroBalanceCta =
+ selectedOrgId != null ? isMoneyRole(selectedOrgRole) : Platform.OS !== 'ios';
+ const zeroBalanceUrl = selectedOrgId
+ ? `${WEB_BASE_URL}/organizations/${selectedOrgId}/payment-details`
+ : `${WEB_BASE_URL}/credits`;
+
const openPicker = () => {
if (!orgs || orgs.length === 0) {
return;
@@ -126,7 +144,7 @@ export function CreditsCard({ enabled, orgs }: Readonly) {
{!balanceLoading && !balanceError && (
- ${balanceDollars.toFixed(2)}
+ {formatDollars(balanceDollars)}
{creditsLoading ? (
@@ -136,11 +154,8 @@ export function CreditsCard({ enabled, orgs }: Readonly) {
earliestExpiry != null && (
- ${expiringTotal.toFixed(2)} in bonus credits expiring{' '}
- {parseTimestamp(earliestExpiry).toLocaleDateString(undefined, {
- month: 'short',
- day: 'numeric',
- })}
+ {formatDollars(expiringTotal)} in bonus credits expiring{' '}
+ {formatDate(parseTimestamp(earliestExpiry))}
)
@@ -149,6 +164,19 @@ export function CreditsCard({ enabled, orgs }: Readonly) {
{balanceFetching && }
)}
+ {!balanceLoading &&
+ !balanceError &&
+ balanceDollars === 0 &&
+ (canShowZeroBalanceCta ? (
+
+ ) : (
+
+
+ Your credit balance is empty. Credits are managed outside the iOS app for this
+ account.
+
+
+ ))}
{enabled && !selectedOrgId ? : null}
);
diff --git a/apps/mobile/src/components/profile-screen.tsx b/apps/mobile/src/components/profile-screen.tsx
index 8f4a961680..077628bc4d 100644
--- a/apps/mobile/src/components/profile-screen.tsx
+++ b/apps/mobile/src/components/profile-screen.tsx
@@ -11,7 +11,7 @@ import {
ShieldCheck,
Trash2,
} from 'lucide-react-native';
-import { Alert, Platform, Pressable, ScrollView, View } from 'react-native';
+import { Alert, Platform, ScrollView, View } from 'react-native';
import { toast } from 'sonner-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
@@ -20,6 +20,7 @@ import { RestorePurchasesButton } from '@/components/kilo-pass/restore-purchases
import { NotificationsCard } from '@/components/notifications-card';
import { ActionTile } from '@/components/profile-action-tile';
import { CreditsCard } from '@/components/profile-credits-card';
+import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
import { ConfigureRow } from '@/components/ui/configure-row';
import { Skeleton } from '@/components/ui/skeleton';
@@ -49,12 +50,18 @@ export function ProfileScreen() {
data,
isLoading,
isError: providersError,
+ isFetching: providersFetching,
refetch: refetchProviders,
} = useQuery({
...trpc.user.getAuthProviders.queryOptions(),
enabled: isAuthenticated,
});
- const { data: orgs, isFetching: organizationsFetching } = useQuery({
+ const {
+ data: orgs,
+ isFetching: organizationsFetching,
+ isError: organizationsError,
+ refetch: refetchOrganizations,
+ } = useQuery({
...trpc.organizations.list.queryOptions(),
enabled: isAuthenticated,
});
@@ -82,12 +89,12 @@ export function ProfileScreen() {
const confirmDeleteAccount = () => {
Alert.alert(
- 'Delete Account?',
+ 'Delete account?',
'This will send a request to permanently delete your account and all associated data. This action cannot be undone.',
[
{ text: 'Cancel', style: 'cancel' },
{
- text: 'Delete Account',
+ text: 'Delete account',
style: 'destructive',
onPress: () => {
deleteAccount.mutate();
@@ -101,7 +108,7 @@ export function ProfileScreen() {
Alert.alert('Sign out?', 'You will need to sign in again to access your workspace.', [
{ text: 'Cancel', style: 'cancel' },
{
- text: 'Sign Out',
+ text: 'Sign out',
style: 'destructive',
onPress: () => {
void signOut();
@@ -164,24 +171,35 @@ export function ProfileScreen() {
Organization
- {
- router.push('/(app)/(tabs)/(3_profile)/organization' as Href);
- }}
- />
+ {organizationsError ? (
+ void refetchOrganizations()}
+ isRetrying={organizationsFetching}
+ />
+ ) : (
+ {
+ router.push('/(app)/(tabs)/(3_profile)/organization' as Href);
+ }}
+ />
+ )}
)}
{/* Linked accounts */}
- Linked Accounts
+ Linked accounts
{isLoading && (
@@ -191,16 +209,13 @@ export function ProfileScreen() {
)}
{providersError && (
- {
- void refetchProviders();
- }}
- >
-
- Failed to load accounts. Tap to retry.
-
-
+ void refetchProviders()}
+ isRetrying={providersFetching}
+ />
)}
{data?.providers.map(p => {
diff --git a/apps/mobile/src/components/query-error.tsx b/apps/mobile/src/components/query-error.tsx
index f652a406e5..e42a807710 100644
--- a/apps/mobile/src/components/query-error.tsx
+++ b/apps/mobile/src/components/query-error.tsx
@@ -1,40 +1,93 @@
-import { WifiOff } from 'lucide-react-native';
-import { View } from 'react-native';
+import {
+ AlertCircle,
+ Lock,
+ type LucideIcon,
+ SearchX,
+ ServerCrash,
+ WifiOff,
+} from 'lucide-react-native';
+import { EmptyState } from '@/components/empty-state';
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
-import { useThemeColors } from '@/lib/hooks/use-theme-colors';
-import { cn } from '@/lib/utils';
+
+export type QueryErrorVariant = 'neutral' | 'offline' | 'permission' | 'not-found' | 'server';
+
+const VARIANT_META: Record<
+ QueryErrorVariant,
+ { icon: LucideIcon; title: string; description: string }
+> = {
+ neutral: {
+ icon: AlertCircle,
+ title: 'Something went wrong',
+ description: 'Please try again.',
+ },
+ offline: {
+ icon: WifiOff,
+ title: 'Failed to load',
+ description: 'Something went wrong',
+ },
+ permission: {
+ icon: Lock,
+ title: 'Access denied',
+ description: "You don't have permission to view this.",
+ },
+ 'not-found': {
+ icon: SearchX,
+ title: 'Not found',
+ description: 'This item may have been removed or is no longer available.',
+ },
+ server: {
+ icon: ServerCrash,
+ title: 'Could not load',
+ description: 'Something went wrong on our end. Please try again.',
+ },
+};
type QueryErrorProps = {
+ variant?: QueryErrorVariant;
+ title?: string;
message?: string;
onRetry?: () => void;
+ isRetrying?: boolean;
className?: string;
+ placement?: 'center' | 'top';
};
export function QueryError({
- message = 'Something went wrong',
+ variant = 'offline',
+ title,
+ message,
onRetry,
+ isRetrying = false,
className,
+ placement = 'center',
}: Readonly) {
- const colors = useThemeColors();
+ const meta = VARIANT_META[variant];
return (
-
-
-
-
-
- Failed to load
-
- {message}
-
-
- {onRetry && (
-
- Retry
-
- )}
-
+
+ Retry
+
+ )
+ }
+ />
);
}
diff --git a/apps/mobile/src/components/rename-modal.tsx b/apps/mobile/src/components/rename-modal.tsx
new file mode 100644
index 0000000000..eb994310b4
--- /dev/null
+++ b/apps/mobile/src/components/rename-modal.tsx
@@ -0,0 +1,119 @@
+import { useEffect, useRef, useState } from 'react';
+import { Modal, Platform, Pressable, TextInput, View } from 'react-native';
+
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { cn } from '@/lib/utils';
+
+type RenameModalProps = {
+ title: string;
+ placeholder: string;
+ initialValue: string;
+ onSave: (name: string) => Promise;
+ onClose: () => void;
+ maxLength?: number;
+};
+
+// Mount this component only while the modal should be open (e.g. `{visible && }`)
+// so each open gets fresh state: current initialValue, a reset canSave, and a re-armed Android autofocus.
+export function RenameModal({
+ title,
+ placeholder,
+ initialValue,
+ onSave,
+ onClose,
+ maxLength = 50,
+}: Readonly) {
+ const colors = useThemeColors();
+ const nameRef = useRef(initialValue);
+ const inputRef = useRef(null);
+ const [canSave, setCanSave] = useState(false);
+ const [pending, setPending] = useState(false);
+ const [errorText, setErrorText] = useState(null);
+
+ // autoFocus doesn't reliably raise the keyboard inside Modal on Android
+ useEffect(() => {
+ if (Platform.OS !== 'android') {
+ return undefined;
+ }
+ const timer = setTimeout(() => {
+ inputRef.current?.focus();
+ }, 100);
+ return () => {
+ clearTimeout(timer);
+ };
+ }, []);
+
+ const handleClose = () => {
+ if (pending) {
+ return;
+ }
+ onClose();
+ };
+
+ const handleSave = async () => {
+ const trimmed = nameRef.current.trim();
+ setPending(true);
+ setErrorText(null);
+ try {
+ await onSave(trimmed);
+ onClose();
+ } catch (error) {
+ setErrorText(error instanceof Error ? error.message : 'Something went wrong');
+ } finally {
+ setPending(false);
+ }
+ };
+
+ return (
+
+
+
+ {
+ e.stopPropagation();
+ }}
+ >
+ {title}
+ {
+ nameRef.current = val;
+ const trimmed = val.trim();
+ setCanSave(trimmed.length > 0 && trimmed !== initialValue);
+ }}
+ autoFocus={Platform.OS !== 'android'}
+ maxLength={maxLength}
+ editable={!pending}
+ accessibilityState={{ disabled: pending }}
+ />
+ {errorText ? {errorText} : null}
+
+
+ Cancel
+
+ {
+ void handleSave();
+ }}
+ disabled={!canSave}
+ loading={pending}
+ >
+ Save
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/repo-toggle-row.tsx b/apps/mobile/src/components/repo-toggle-row.tsx
new file mode 100644
index 0000000000..f503c81573
--- /dev/null
+++ b/apps/mobile/src/components/repo-toggle-row.tsx
@@ -0,0 +1,43 @@
+import { Lock } from 'lucide-react-native';
+import { View } from 'react-native';
+
+import { Text } from '@/components/ui/text';
+import { ChoiceRow } from '@/components/ui/choice-row';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+
+/**
+ * Repository checkbox row (lock icon for private repos + full name + check)
+ * shared by code-reviewer's and security-agent's repository pickers.
+ */
+export function RepoToggleRow({
+ repo,
+ selected,
+ disabled,
+ onPress,
+ className,
+}: Readonly<{
+ repo: { id: number | string; fullName: string; private?: boolean };
+ selected: boolean;
+ disabled?: boolean;
+ onPress: () => void;
+ className?: string;
+}>) {
+ const colors = useThemeColors();
+
+ return (
+
+
+ {repo.private ? : null}
+
+ {repo.fullName}
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/screen-header.tsx b/apps/mobile/src/components/screen-header.tsx
index 798da347aa..0c8594c368 100644
--- a/apps/mobile/src/components/screen-header.tsx
+++ b/apps/mobile/src/components/screen-header.tsx
@@ -57,7 +57,7 @@ export function ScreenHeader({
let titleNode: React.ReactNode = null;
if (title != null) {
const titleText = (
-
+
{title}
);
@@ -92,7 +92,7 @@ export function ScreenHeader({
}}
hitSlop={12}
accessibilityRole="button"
- accessibilityLabel="Go back"
+ accessibilityLabel={resolvedBackIcon === 'close' ? 'Close' : 'Go back'}
className="-ml-1 mr-1 active:opacity-70"
>
{resolvedBackIcon === 'close' ? (
diff --git a/apps/mobile/src/components/security-agent/analysis-settings-screen.tsx b/apps/mobile/src/components/security-agent/analysis-settings-screen.tsx
index 1980fc7d65..b2faccda4d 100644
--- a/apps/mobile/src/components/security-agent/analysis-settings-screen.tsx
+++ b/apps/mobile/src/components/security-agent/analysis-settings-screen.tsx
@@ -5,15 +5,17 @@ import {
import { useRouter } from 'expo-router';
import { Brain, Search, Wrench } from 'lucide-react-native';
import { useEffect, useRef, useState } from 'react';
-import { Pressable, ScrollView, View } from 'react-native';
+import { Pressable, View } from 'react-native';
import { SettingsSaveButton } from '@/components/security-agent/settings-save-button';
import { openModelPicker } from '@/components/agents/model-selector';
+import { PlatformErrorScreen } from '@/components/platform-error-screen';
import { ScreenHeader } from '@/components/screen-header';
import { QueryError } from '@/components/query-error';
import { ConfigureRow } from '@/components/ui/configure-row';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
+import { TabScreenScrollView } from '@/components/tab-screen';
import { useAvailableModels } from '@/lib/hooks/use-available-models';
import {
useSecurityAgentSettingsRedirect,
@@ -21,8 +23,8 @@ import {
} from '@/lib/hooks/use-settings-back-guard';
import {
useSaveSecurityAgentConfig,
+ useSecurityAgentCapability,
useSecurityAgentConfig,
- useSecurityAgentEditCapability,
} from '@/lib/hooks/use-security-agent';
import { type SecurityAgentConfig } from '@/lib/security-agent';
import { cn } from '@/lib/utils';
@@ -38,7 +40,7 @@ const ANALYSIS_MODES: { value: AnalysisMode; label: string }[] = [
function AnalysisSettingsSkeleton() {
return (
-
+
@@ -51,12 +53,15 @@ function AnalysisSettingsSkeleton() {
export function AnalysisSettingsScreen({ scope }: Readonly<{ scope: string }>) {
const router = useRouter();
- const canManage = useSecurityAgentEditCapability(scope);
+ const canManage = useSecurityAgentCapability(scope).canManage;
const config = useSecurityAgentConfig(scope);
const save = useSaveSecurityAgentConfig(scope);
- const { models, isLoading: modelsLoading } = useAvailableModels(
- isPersonalSecurityScope(scope) ? undefined : scope
- );
+ const {
+ models,
+ isLoading: modelsLoading,
+ isError: modelsError,
+ refetch: refetchModels,
+ } = useAvailableModels(isPersonalSecurityScope(scope) ? undefined : scope);
const [triageModelSlug, setTriageModelSlug] = useState('');
const [analysisModelSlug, setAnalysisModelSlug] = useState('');
@@ -97,20 +102,19 @@ export function AnalysisSettingsScreen({ scope }: Readonly<{ scope: string }>) {
const handleSave = async () => {
await save.mutateAsync(patch);
+ initialConfigRef.current = { ...initialConfigRef.current, ...patch };
};
- const { onBack } = useSettingsBackGuard({ dirty, valid, onSave: handleSave });
+ const { onBack, skipNextGuardRef } = useSettingsBackGuard({ dirty, valid, onSave: handleSave });
if (config.isError && !config.data) {
return (
-
-
- void config.refetch()}
- />
-
+ void config.refetch()}
+ />
);
}
if (config.isLoading || !config.data) {
@@ -131,7 +135,7 @@ export function AnalysisSettingsScreen({ scope }: Readonly<{ scope: string }>) {
return (
) {
valid={valid}
pending={save.isPending}
onSave={handleSave}
+ skipNextGuardRef={skipNextGuardRef}
/>
) : undefined
}
/>
-
+
+ {!canManage && (
+
+ Only organization owners and billing managers can change these settings.
+
+ )}
Analysis depth
@@ -158,13 +168,14 @@ export function AnalysisSettingsScreen({ scope }: Readonly<{ scope: string }>) {
disabled={!canManage}
className={cn(
'flex-1 items-center rounded-full py-2 active:opacity-70',
- active && 'bg-foreground'
+ active && 'bg-foreground',
+ !canManage && 'opacity-50'
)}
onPress={() => {
setAnalysisMode(option.value);
}}
accessibilityRole="radio"
- accessibilityState={{ selected: active }}
+ accessibilityState={{ selected: active, disabled: !canManage }}
>
) {
-
-
-
-
+
+
+
+
+ )}
+ {modelsError && (
+ void refetchModels()}
+ isRetrying={modelsLoading}
/>
-
-
- {!canManage && (
-
- Only organization owners and billing managers can change these settings.
-
)}
-
+ {!modelsLoading && !modelsError && (
+
+
+
+
+
+ )}
+
);
}
diff --git a/apps/mobile/src/components/security-agent/audit-report-button.tsx b/apps/mobile/src/components/security-agent/audit-report-button.tsx
new file mode 100644
index 0000000000..c21e112972
--- /dev/null
+++ b/apps/mobile/src/components/security-agent/audit-report-button.tsx
@@ -0,0 +1,31 @@
+import { getSecurityAgentAuditUrl } from '@kilocode/app-shared/security-agent';
+import { MoreHorizontal } from 'lucide-react-native';
+import { Pressable } from 'react-native';
+
+import { WEB_BASE_URL } from '@/lib/config';
+import { openExternalUrl } from '@/lib/external-link';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+
+/**
+ * Header action that opens the web audit report directly — shared by the
+ * dashboard, scope-entry, and settings-overview screens, all of which show
+ * it only when the viewer can manage Security Agent for this scope.
+ */
+export function AuditReportButton({ scope }: Readonly<{ scope: string }>) {
+ const colors = useThemeColors();
+
+ return (
+ {
+ void openExternalUrl(getSecurityAgentAuditUrl(WEB_BASE_URL, scope), {
+ label: 'audit report',
+ });
+ }}
+ accessibilityRole="button"
+ accessibilityLabel="View audit report"
+ className="size-11 items-center justify-center active:opacity-70"
+ >
+
+
+ );
+}
diff --git a/apps/mobile/src/components/security-agent/automation-settings-screen.tsx b/apps/mobile/src/components/security-agent/automation-settings-screen.tsx
index 2db94ec7db..77bebe74dd 100644
--- a/apps/mobile/src/components/security-agent/automation-settings-screen.tsx
+++ b/apps/mobile/src/components/security-agent/automation-settings-screen.tsx
@@ -1,23 +1,24 @@
import { getSettingsDirtyState } from '@kilocode/app-shared/security-agent';
import { useEffect, useRef, useState } from 'react';
-import { ScrollView, View } from 'react-native';
+import { View } from 'react-native';
import { toast } from 'sonner-native';
import { PillGroup } from '@/components/security-agent/settings-pill-group';
import { SettingsSaveButton } from '@/components/security-agent/settings-save-button';
import { ToggleRow } from '@/components/security-agent/settings-toggle-row';
+import { PlatformErrorScreen } from '@/components/platform-error-screen';
import { ScreenHeader } from '@/components/screen-header';
-import { QueryError } from '@/components/query-error';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
+import { TabScreenScrollView } from '@/components/tab-screen';
import {
useSecurityAgentSettingsRedirect,
useSettingsBackGuard,
} from '@/lib/hooks/use-settings-back-guard';
import {
useSaveSecurityAgentConfig,
+ useSecurityAgentCapability,
useSecurityAgentConfig,
- useSecurityAgentEditCapability,
useTrackSecurityAgentInteraction,
} from '@/lib/hooks/use-security-agent';
import { type SecurityAgentConfig } from '@/lib/security-agent';
@@ -54,7 +55,7 @@ function AutomationSettingsSkeleton() {
}
export function AutomationSettingsScreen({ scope }: Readonly<{ scope: string }>) {
- const canManage = useSecurityAgentEditCapability(scope);
+ const canManage = useSecurityAgentCapability(scope).canManage;
const config = useSecurityAgentConfig(scope);
const save = useSaveSecurityAgentConfig(scope);
const trackInteraction = useTrackSecurityAgentInteraction(scope);
@@ -125,6 +126,7 @@ export function AutomationSettingsScreen({ scope }: Readonly<{ scope: string }>)
const handleSave = async () => {
const result = await save.mutateAsync(patch);
+ initialConfigRef.current = { ...initialConfigRef.current, ...patch };
if (result.existingFindingsQueuedCount) {
toast.success(
`${result.existingFindingsQueuedCount} existing finding${result.existingFindingsQueuedCount === 1 ? '' : 's'} queued for analysis.`
@@ -132,18 +134,16 @@ export function AutomationSettingsScreen({ scope }: Readonly<{ scope: string }>)
}
};
- const { onBack } = useSettingsBackGuard({ dirty, valid, onSave: handleSave });
+ const { onBack, skipNextGuardRef } = useSettingsBackGuard({ dirty, valid, onSave: handleSave });
if (config.isError && !config.data) {
return (
-
-
- void config.refetch()}
- />
-
+ void config.refetch()}
+ />
);
}
if (config.isLoading || !config.data) {
@@ -165,14 +165,15 @@ export function AutomationSettingsScreen({ scope }: Readonly<{ scope: string }>)
valid={valid}
pending={save.isPending}
onSave={handleSave}
+ skipNextGuardRef={skipNextGuardRef}
/>
) : undefined
}
/>
-
+
- Auto Analysis
+ Auto analysis
)
- Auto Remediation
+ Auto remediation
)
- Auto Dismiss
+ Auto dismiss
)
Only organization owners and billing managers can change these settings.
)}
-
+
);
}
diff --git a/apps/mobile/src/components/security-agent/collapsible-section.tsx b/apps/mobile/src/components/security-agent/collapsible-section.tsx
index 597a124580..b8921581e8 100644
--- a/apps/mobile/src/components/security-agent/collapsible-section.tsx
+++ b/apps/mobile/src/components/security-agent/collapsible-section.tsx
@@ -49,6 +49,7 @@ export function CollapsibleSection({
>
{
setExpanded(current => !current);
}}
diff --git a/apps/mobile/src/components/security-agent/dashboard-screen.tsx b/apps/mobile/src/components/security-agent/dashboard-screen.tsx
index 7d2ffc1323..e2042a2803 100644
--- a/apps/mobile/src/components/security-agent/dashboard-screen.tsx
+++ b/apps/mobile/src/components/security-agent/dashboard-screen.tsx
@@ -1,25 +1,27 @@
import {
buildSecurityDashboardMetrics,
type DashboardMetricTone,
- getSecurityAgentAuditUrl,
getSecurityRepositoriesInScope,
} from '@kilocode/app-shared/security-agent';
import { useActionSheet } from '@expo/react-native-action-sheet';
import { useRouter } from 'expo-router';
-import * as WebBrowser from 'expo-web-browser';
-import { MoreHorizontal, RefreshCw, Settings, ShieldAlert } from 'lucide-react-native';
+import { RefreshCw, Settings, ShieldAlert } from 'lucide-react-native';
import { useState } from 'react';
-import { Pressable, RefreshControl, ScrollView, View } from 'react-native';
+import { Pressable, RefreshControl, View } from 'react-native';
+import { toast } from 'sonner-native';
+import { QueryError } from '@/components/query-error';
+import { AuditReportButton } from '@/components/security-agent/audit-report-button';
import { ScreenHeader } from '@/components/screen-header';
import { DashboardSections } from '@/components/security-agent/dashboard-sections';
import { Skeleton } from '@/components/ui/skeleton';
+import { SpinningIcon } from '@/components/ui/spinning-icon';
import { Text } from '@/components/ui/text';
-import { WEB_BASE_URL } from '@/lib/config';
+import { TabScreenScrollView } from '@/components/tab-screen';
import {
+ useSecurityAgentCapability,
useSecurityAgentConfig,
useSecurityAgentDashboardStats,
- useSecurityAgentEditCapability,
useSecurityAgentLastSyncTime,
useSecurityAgentRepositories,
useTriggerSecuritySync,
@@ -40,12 +42,13 @@ export function DashboardScreen({ scope }: Readonly<{ scope: string }>) {
const { showActionSheetWithOptions } = useActionSheet();
const [repoFullName, setRepoFullName] = useState(undefined);
const [refreshing, setRefreshing] = useState(false);
+ const [refreshFailed, setRefreshFailed] = useState(false);
const config = useSecurityAgentConfig(scope);
const dashboardStats = useSecurityAgentDashboardStats(scope, repoFullName);
const lastSync = useSecurityAgentLastSyncTime(scope, repoFullName);
const repositories = useSecurityAgentRepositories(scope);
- const canManage = useSecurityAgentEditCapability(scope);
+ const canManage = useSecurityAgentCapability(scope).canManage;
const triggerSync = useTriggerSecuritySync(scope);
const slaEnabled = config.data?.slaEnabled ?? true;
@@ -53,23 +56,40 @@ export function DashboardScreen({ scope }: Readonly<{ scope: string }>) {
const metrics = data ? buildSecurityDashboardMetrics(data, slaEnabled) : [];
const lastSyncTime = lastSync.data?.lastSyncTime;
- const lastSyncLabel = lastSyncTime
- ? `Last synced ${timeAgo(parseTimestamp(lastSyncTime))}`
- : 'Not yet synced';
+ let lastSyncLabel = 'Not yet synced';
+ if (lastSync.isError) {
+ lastSyncLabel = 'Sync status unavailable';
+ } else if (lastSyncTime) {
+ lastSyncLabel = `Last synced ${timeAgo(parseTimestamp(lastSyncTime))}`;
+ }
const handleRefresh = () => {
void (async () => {
setRefreshing(true);
+ setRefreshFailed(false);
try {
- // Refresh only — never triggers a new sync.
- await Promise.all([dashboardStats.refetch(), lastSync.refetch()]);
+ // Refresh only — never triggers a new sync. Stale data stays on
+ // screen either way; a failed refresh just surfaces a brief warning.
+ const [statsResult, syncResult] = await Promise.all([
+ dashboardStats.refetch(),
+ lastSync.refetch(),
+ ]);
+ setRefreshFailed(statsResult.isError || syncResult.isError);
} finally {
setRefreshing(false);
}
})();
};
+ // Repos aren't known yet (still loading or the fetch failed) — the filter
+ // stays disabled instead of silently offering a shrunken "All repositories
+ // only" option list.
+ const repoFilterUnavailable = repositories.isLoading || repositories.isError;
+
const openRepoFilter = () => {
+ if (repoFilterUnavailable) {
+ return;
+ }
const repoNames = getSecurityRepositoriesInScope(repositories.data ?? [], config.data).map(
repo => repo.fullName
);
@@ -82,15 +102,6 @@ export function DashboardScreen({ scope }: Readonly<{ scope: string }>) {
});
};
- const openMoreActions = () => {
- const options = ['View audit report', 'Cancel'];
- showActionSheetWithOptions({ options, cancelButtonIndex: 1 }, index => {
- if (index === 0) {
- void WebBrowser.openBrowserAsync(getSecurityAgentAuditUrl(WEB_BASE_URL, scope));
- }
- });
- };
-
return (
) {
>
- {canManage ? (
-
-
-
- ) : null}
+ {canManage ? : null}
}
/>
- }
>
{repoFullName ?? 'All repositories'}
@@ -152,17 +156,34 @@ export function DashboardScreen({ scope }: Readonly<{ scope: string }>) {
{
- triggerSync.mutate({ repoFullName });
+ triggerSync.mutate(
+ { repoFullName },
+ {
+ onSuccess: () => {
+ toast.success('Sync queued');
+ },
+ }
+ );
}}
disabled={triggerSync.isPending}
accessibilityRole="button"
accessibilityLabel="Sync now"
+ accessibilityState={{ disabled: triggerSync.isPending, busy: triggerSync.isPending }}
className="size-11 items-center justify-center active:opacity-70"
>
-
+
+ {refreshFailed ? (
+ Could not refresh — showing last synced data.
+ ) : null}
+
{dashboardStats.isLoading ? (
@@ -190,17 +211,51 @@ export function DashboardScreen({ scope }: Readonly<{ scope: string }>) {
)}
+ {slaEnabled && data && data.sla.overall.total === 0 ? (
+
+ {
+ triggerSync.mutate(
+ { repoFullName },
+ {
+ onSuccess: () => {
+ toast.success('Sync queued');
+ },
+ }
+ );
+ }}
+ disabled={triggerSync.isPending}
+ accessibilityRole="button"
+ accessibilityLabel="Sync findings"
+ accessibilityState={{ disabled: triggerSync.isPending, busy: triggerSync.isPending }}
+ className="min-h-11 flex-row items-center gap-1.5 active:opacity-70"
+ >
+ {triggerSync.isPending && (
+
+ )}
+ Sync findings
+
+ {
+ router.push(getSecurityAgentPath(scope, 'settings/repositories'));
+ }}
+ accessibilityRole="button"
+ accessibilityLabel="Manage repositories"
+ className="min-h-11 justify-center active:opacity-70"
+ >
+ Manage repositories
+
+
+ ) : null}
+
{dashboardStats.isError && !data ? (
- {
- void dashboardStats.refetch();
- }}
- >
-
- Could not load dashboard data. Tap to retry.
-
-
+ void dashboardStats.refetch()}
+ isRetrying={dashboardStats.isFetching}
+ />
) : null}
{data ? (
@@ -211,7 +266,7 @@ export function DashboardScreen({ scope }: Readonly<{ scope: string }>) {
repoFullName={repoFullName}
/>
) : null}
-
+
);
}
diff --git a/apps/mobile/src/components/security-agent/dashboard-sections.tsx b/apps/mobile/src/components/security-agent/dashboard-sections.tsx
index bfada89792..5b9925265d 100644
--- a/apps/mobile/src/components/security-agent/dashboard-sections.tsx
+++ b/apps/mobile/src/components/security-agent/dashboard-sections.tsx
@@ -1,9 +1,10 @@
import { getAnalysisIncompleteCount } from '@kilocode/app-shared/security-agent';
import { type Href, useRouter } from 'expo-router';
-import { ArrowRight, ShieldCheck } from 'lucide-react-native';
+import { ArrowRight, FolderGit2, ShieldCheck } from 'lucide-react-native';
import { type ReactNode } from 'react';
import { Pressable, View } from 'react-native';
+import { EmptyState } from '@/components/empty-state';
import { KvRow } from '@/components/ui/kv-row';
import { Text } from '@/components/ui/text';
import { type useSecurityAgentDashboardStats } from '@/lib/hooks/use-security-agent';
@@ -238,9 +239,13 @@ function RepoHealthSection({ scope, data, slaEnabled }: SectionProps) {
if (data.repoHealth.length === 0) {
return (
-
- Repository priorities will appear after findings are synced.
-
+
);
}
diff --git a/apps/mobile/src/components/security-agent/dismiss-finding-screen.tsx b/apps/mobile/src/components/security-agent/dismiss-finding-screen.tsx
index 0fd2261384..59d47fcdd9 100644
--- a/apps/mobile/src/components/security-agent/dismiss-finding-screen.tsx
+++ b/apps/mobile/src/components/security-agent/dismiss-finding-screen.tsx
@@ -1,35 +1,55 @@
import { useRouter } from 'expo-router';
-import { Check } from 'lucide-react-native';
+import { ShieldOff } from 'lucide-react-native';
import { useRef, useState } from 'react';
-import { ActivityIndicator, Pressable, ScrollView, TextInput, View } from 'react-native';
+import { ActivityIndicator, ScrollView, TextInput, View } from 'react-native';
+import { EmptyState } from '@/components/empty-state';
+import { QueryError } from '@/components/query-error';
+import { ScreenHeader } from '@/components/screen-header';
+import { PillGroup } from '@/components/security-agent/settings-pill-group';
import { Button } from '@/components/ui/button';
+import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
-import { useDismissSecurityFinding } from '@/lib/hooks/use-security-findings';
+import { useSecurityAgentCapability } from '@/lib/hooks/use-security-agent';
+import { useDismissSecurityFinding, useSecurityFinding } from '@/lib/hooks/use-security-findings';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
-import { cn } from '@/lib/utils';
// The five GitHub dismissal reasons the backend's DismissReasonSchema accepts
// (apps/web/src/lib/security-agent/core/schemas.ts:13-19) — exact value/label
// pairs from the task brief, matching web's dismissal reason picker.
const DISMISS_REASONS = [
- ['fix_started', 'A fix has already started'],
- ['no_bandwidth', 'No bandwidth is available'],
- ['tolerable_risk', 'The risk is tolerable'],
- ['inaccurate', 'The finding is inaccurate'],
- ['not_used', 'Vulnerable code is not used'],
+ { value: 'fix_started', label: 'A fix has already started' },
+ { value: 'no_bandwidth', label: 'No bandwidth is available' },
+ { value: 'tolerable_risk', label: 'The risk is tolerable' },
+ { value: 'inaccurate', label: 'The finding is inaccurate' },
+ { value: 'not_used', label: 'Vulnerable code is not used' },
] as const;
-type DismissReason = (typeof DISMISS_REASONS)[number][0];
+type DismissReason = (typeof DISMISS_REASONS)[number]['value'];
type DismissFindingScreenProps = {
scope: string;
findingId: string;
};
+function DismissFindingSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
export function DismissFindingScreen({ scope, findingId }: Readonly) {
const router = useRouter();
const colors = useThemeColors();
+ const capability = useSecurityAgentCapability(scope);
+ const findingQuery = useSecurityFinding(scope, findingId);
const [reason, setReason] = useState(null);
const commentRef = useRef('');
const dismissFinding = useDismissSecurityFinding(scope);
@@ -51,65 +71,132 @@ export function DismissFindingScreen({ scope, findingId }: Readonly
- Dismiss Finding
-
-
- Reason
-
-
- {DISMISS_REASONS.map(([value, label], index) => {
- const selected = reason === value;
- return (
- {
- setReason(value);
- }}
- accessibilityRole="radio"
- accessibilityState={{ selected }}
- >
- {label}
- {selected && }
-
- );
- })}
-
+ // Load the finding (and the manage capability it depends on) before the
+ // form ever mounts — an invalid/fixed/dismissed finding, or a viewer
+ // without manage rights, must never see an editable form that only fails
+ // once submitted to the backend.
+ const errorCode = findingQuery.error?.data?.code;
+ const notFound = findingQuery.isError && (errorCode === 'NOT_FOUND' || errorCode === 'FORBIDDEN');
+
+ if (notFound) {
+ return (
+
+
+
+
+ );
+ }
+
+ if (findingQuery.isError) {
+ return (
+
+
+ void findingQuery.refetch()}
+ />
+ );
+ }
-
-
- Comment (optional)
-
- {
- commentRef.current = value;
- }}
+ if (capability.isError) {
+ return (
+
+
+ void capability.refetch()}
/>
+ );
+ }
+
+ if (findingQuery.isLoading || !findingQuery.data || capability.isLoading) {
+ return ;
+ }
+
+ const finding = findingQuery.data;
+
+ if (!capability.canManage) {
+ return (
+
+
+
+
+ );
+ }
+
+ if (finding.status !== 'open') {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ Comment (optional)
+
+ {
+ commentRef.current = value;
+ }}
+ />
+
+
+ {dismissFinding.isError && (
+ {dismissFinding.error.message}
+ )}
-
- {dismissFinding.isPending ? (
-
- ) : null}
- Dismiss finding
-
-
+
+ {dismissFinding.isPending ? (
+
+ ) : null}
+ Dismiss finding
+
+
+
);
}
diff --git a/apps/mobile/src/components/security-agent/finding-analysis-panel.tsx b/apps/mobile/src/components/security-agent/finding-analysis-panel.tsx
index 35d53742de..3a5ffe6fc5 100644
--- a/apps/mobile/src/components/security-agent/finding-analysis-panel.tsx
+++ b/apps/mobile/src/components/security-agent/finding-analysis-panel.tsx
@@ -4,12 +4,13 @@ import {
isPersonalSecurityScope,
} from '@kilocode/app-shared/security-agent';
import { type Href, useRouter } from 'expo-router';
-import { ExternalLink } from 'lucide-react-native';
+import { ExternalLink, ScanSearch } from 'lucide-react-native';
import { ActivityIndicator, Alert, Pressable, View } from 'react-native';
import { MarkdownText } from '@/components/agents/markdown-text';
import { CollapsibleSection } from '@/components/security-agent/collapsible-section';
import { FindingStatusBadge } from '@/components/security-agent/finding-status-badge';
+import { EmptyState } from '@/components/empty-state';
import { QueryError } from '@/components/query-error';
import { Button } from '@/components/ui/button';
import { KvRow } from '@/components/ui/kv-row';
@@ -83,7 +84,14 @@ export function FindingAnalysisPanel({
}
if (!analysis) {
- return null;
+ return (
+
+ );
}
const presentation = getSecurityAnalysisDetailPresentation(
@@ -111,6 +119,10 @@ export function FindingAnalysisPanel({
capacity.runningCount !== undefined &&
capacity.concurrencyLimit !== undefined &&
capacity.runningCount < capacity.concurrencyLimit;
+ // "Capacity full" is only ever true after a successful count — while
+ // loading or on error, `hasCapacity` is false too (so the button stays
+ // disabled), but neither of those states means it's actually full.
+ const capacityConfirmedFull = !capacity.isLoading && !capacity.isError && !hasCapacity;
const handleStartAnalysis = () => {
startAnalysis.mutate({
@@ -143,6 +155,7 @@ export function FindingAnalysisPanel({
icon={presentation.icon}
label={presentation.title}
tone={presentation.tone}
+ spinning={presentation.spinning}
/>
{presentation.description}
@@ -164,11 +177,25 @@ export function FindingAnalysisPanel({
) : null}
- {canStartAnalysis && !hasCapacity ? (
+ {canStartAnalysis && capacityConfirmedFull ? (
Analysis capacity is full. Wait for an active analysis to finish.
) : null}
+ {canStartAnalysis && capacity.isError ? (
+
+
+ Could not check analysis capacity.
+
+ void capacity.refetch()}
+ accessibilityRole="button"
+ accessibilityLabel="Retry"
+ >
+ Retry
+
+
+ ) : null}
{canRestartAnalysis ? (
) {
const router = useRouter();
const colors = useThemeColors();
+ const paddingBottom = useTabBarBottomPadding();
const [tab, setTab] = useState('details');
const findingQuery = useSecurityFinding(scope, findingId);
const analysisQuery = useSecurityAnalysis(scope, findingId);
const trackInteraction = useTrackSecurityAgentInteraction(scope);
- const role = useSecurityAgentOrgRole(scope);
+ const capability = useSecurityAgentCapability(scope);
// Ref indirection keeps the tracking effects independent of the mutation
// object's identity (a new object every render), so they only re-fire on
@@ -85,12 +86,14 @@ export function FindingDetailScreen({ scope, findingId }: Readonly
-
+
+
+
);
}
@@ -99,7 +102,7 @@ export function FindingDetailScreen({ scope, findingId }: Readonly
-
+
void findingQuery.refetch()}
@@ -124,7 +127,7 @@ export function FindingDetailScreen({ scope, findingId }: Readonly
@@ -139,8 +142,7 @@ export function FindingDetailScreen({ scope, findingId }: Readonly
@@ -154,7 +156,7 @@ export function FindingDetailScreen({ scope, findingId }: Readonly {
@@ -175,7 +177,7 @@ export function FindingDetailScreen({ scope, findingId }: Readonly
-
+
{tab === 'details' && }
{tab === 'analysis' && (
void analysisQuery.refetch()}
/>
)}
-
+
);
}
diff --git a/apps/mobile/src/components/security-agent/finding-filter-modal.tsx b/apps/mobile/src/components/security-agent/finding-filter-modal.tsx
index 79127cb57e..c02b132bc4 100644
--- a/apps/mobile/src/components/security-agent/finding-filter-modal.tsx
+++ b/apps/mobile/src/components/security-agent/finding-filter-modal.tsx
@@ -8,13 +8,12 @@ import {
selectSecurityFindingOutcome,
selectSecurityFindingStatus,
} from '@kilocode/app-shared/security-agent';
-import { Check } from 'lucide-react-native';
import { useState } from 'react';
-import { Modal, Pressable, ScrollView, View } from 'react-native';
+import { ScrollView, View } from 'react-native';
import { Button } from '@/components/ui/button';
+import { ChoiceRow } from '@/components/ui/choice-row';
import { Text } from '@/components/ui/text';
-import { useThemeColors } from '@/lib/hooks/use-theme-colors';
const STATUS_OPTIONS: { value: SecurityFindingStatusFilter; label: string }[] = [
{ value: 'open', label: 'Open' },
@@ -52,6 +51,11 @@ const SORT_OPTIONS: { value: SecurityFindingSortBy; label: string }[] = [
{ value: 'sla_due_at_asc', label: 'SLA due date' },
];
+const SLA_STATUS_OPTIONS: { value: boolean; label: string }[] = [
+ { value: false, label: 'All' },
+ { value: true, label: 'Overdue only' },
+];
+
type FindingRepositoryOption = {
fullName: string;
};
@@ -70,20 +74,12 @@ type FilterOptionRowProps = {
};
function FilterOptionRow({ label, isSelected, onPress }: Readonly) {
- const colors = useThemeColors();
-
return (
-
+
{label}
- {isSelected && }
-
+
);
}
@@ -131,83 +127,86 @@ export function FindingFilterModal({
];
return (
-
-
-
-
-
-
- Filter Findings
-
-
- {
- setDraft(prev => ({ ...prev, repoFullName }));
- }}
- />
- {
- setDraft(prev => selectSecurityFindingStatus(prev, status));
- }}
- />
- {
- setDraft(prev => ({ ...prev, severity }));
- }}
- />
- {
- setDraft(prev => selectSecurityFindingOutcome(prev, outcome));
- }}
- />
- {
- setDraft(prev => ({ ...prev, sortBy }));
- }}
- />
-
-
-
- {
- onApply(DEFAULT_SECURITY_FINDING_FILTERS);
- onClose();
- }}
- >
- Reset
-
-
-
- Cancel
-
- {
- onApply(draft);
- onClose();
- }}
- >
- Apply
-
-
-
+
+ Filter findings
+
+ {
+ setDraft(prev => ({ ...prev, repoFullName }));
+ }}
+ />
+ {
+ setDraft(prev => selectSecurityFindingStatus(prev, status));
+ }}
+ />
+ {
+ setDraft(prev => ({ ...prev, severity }));
+ }}
+ />
+ {
+ setDraft(prev => selectSecurityFindingOutcome(prev, outcome));
+ }}
+ />
+ {
+ setDraft(prev => ({ ...prev, overdue: overdue ? true : undefined }));
+ }}
+ />
+ {
+ setDraft(prev => ({ ...prev, sortBy }));
+ }}
+ />
+
+
+ {
+ onApply(DEFAULT_SECURITY_FINDING_FILTERS);
+ onClose();
+ }}
+ >
+ Reset
+
+
+
+ Cancel
+
+ {
+ onApply(draft);
+ onClose();
+ }}
+ >
+ Apply
+
-
+
);
}
diff --git a/apps/mobile/src/components/security-agent/finding-list-screen.tsx b/apps/mobile/src/components/security-agent/finding-list-screen.tsx
index 69d1c3840f..f2973aa7cf 100644
--- a/apps/mobile/src/components/security-agent/finding-list-screen.tsx
+++ b/apps/mobile/src/components/security-agent/finding-list-screen.tsx
@@ -6,15 +6,17 @@ import {
type SecurityFindingRouteParams,
toSecurityFindingQuery,
} from '@kilocode/app-shared/security-agent';
+import { useRouter } from 'expo-router';
import { ShieldCheck, SlidersHorizontal } from 'lucide-react-native';
import { useMemo, useState } from 'react';
import { FlatList, Pressable, RefreshControl, View } from 'react-native';
+import { toast } from 'sonner-native';
import { EmptyState } from '@/components/empty-state';
import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
-import { FindingFilterModal } from '@/components/security-agent/finding-filter-modal';
import { FindingRow } from '@/components/security-agent/finding-row';
+import { useTabBarBottomPadding } from '@/components/tab-screen';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
@@ -24,7 +26,10 @@ import {
useSecurityAnalysisCapacity,
} from '@/lib/hooks/use-security-agent';
import { useSecurityFindings } from '@/lib/hooks/use-security-findings';
+import { getSecurityAgentPath } from '@/lib/security-agent';
+import { setSecurityFindingFilterBridge } from '@/lib/security-finding-filter-bridge';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { cn } from '@/lib/utils';
type FindingListScreenProps = {
scope: string;
@@ -46,9 +51,10 @@ function FindingsListFooter({
}
export function FindingListScreen({ scope, routeParams }: Readonly) {
+ const router = useRouter();
const colors = useThemeColors();
+ const paddingBottom = useTabBarBottomPadding();
const [filters, setFilters] = useState(() => parseSecurityFindingFilters(routeParams));
- const [showFilterModal, setShowFilterModal] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const config = useSecurityAgentConfig(scope);
@@ -65,13 +71,19 @@ export function FindingListScreen({ scope, routeParams }: Readonly page.findings) ?? [];
const scopedRepositories = getSecurityRepositoriesInScope(repositories.data ?? [], config.data);
+ // Repos aren't known yet (still loading or the fetch failed) — the filter
+ // stays disabled instead of silently offering a shrunken repository list.
+ const filterUnavailable = repositories.isLoading || repositories.isError;
const handleRefresh = () => {
void (async () => {
setRefreshing(true);
try {
// Refresh only — never triggers a new sync.
- await findings.refetch();
+ const result = await findings.refetch();
+ if (result.isError) {
+ toast.error('Could not refresh. Showing the last saved findings.');
+ }
} finally {
setRefreshing(false);
}
@@ -85,11 +97,24 @@ export function FindingListScreen({ scope, routeParams }: Readonly {
- setShowFilterModal(true);
+ if (filterUnavailable) {
+ return;
+ }
+ setSecurityFindingFilterBridge({
+ filters,
+ repositories: scopedRepositories,
+ onApply: setFilters,
+ });
+ router.push(getSecurityAgentPath(scope, 'filter'));
}}
+ disabled={filterUnavailable}
accessibilityRole="button"
accessibilityLabel="Filter findings"
- className="size-11 items-center justify-center active:opacity-70"
+ accessibilityState={{ disabled: filterUnavailable }}
+ className={cn(
+ 'size-11 items-center justify-center active:opacity-70',
+ filterUnavailable && 'opacity-50'
+ )}
>
)}
- contentContainerClassName="gap-3 px-6 pb-24 pt-4"
+ contentContainerClassName="grow gap-3 px-6 pt-4"
+ contentContainerStyle={{ paddingBottom }}
refreshControl={}
onEndReached={() => {
if (findings.hasNextPage && !findings.isFetchingNextPage) {
@@ -143,11 +169,10 @@ export function FindingListScreen({ scope, routeParams }: Readonly
- Reset filters
+ Clear filters
) : undefined
}
@@ -166,16 +191,6 @@ export function FindingListScreen({ scope, routeParams }: Readonly
)}
- {showFilterModal && (
- {
- setShowFilterModal(false);
- }}
- onApply={setFilters}
- />
- )}
);
}
diff --git a/apps/mobile/src/components/security-agent/finding-remediation-panel.tsx b/apps/mobile/src/components/security-agent/finding-remediation-panel.tsx
index 142d654d3e..57fb117728 100644
--- a/apps/mobile/src/components/security-agent/finding-remediation-panel.tsx
+++ b/apps/mobile/src/components/security-agent/finding-remediation-panel.tsx
@@ -4,10 +4,12 @@ import {
getRemediationStatusPresentation,
getRemediationUnavailableCopy,
} from '@kilocode/app-shared/security-agent';
+import { Wrench } from 'lucide-react-native';
import { ActivityIndicator, Alert, Linking, View } from 'react-native';
import { CollapsibleSection } from '@/components/security-agent/collapsible-section';
import { FindingStatusBadge } from '@/components/security-agent/finding-status-badge';
+import { EmptyState } from '@/components/empty-state';
import { QueryError } from '@/components/query-error';
import { Button } from '@/components/ui/button';
import { KvRow } from '@/components/ui/kv-row';
@@ -67,7 +69,14 @@ export function FindingRemediationPanel({
}
if (!analysis) {
- return null;
+ return (
+
+ );
}
const { remediationCapability, remediationSummary, remediationAttempts } = analysis;
@@ -93,6 +102,7 @@ export function FindingRemediationPanel({
icon={presentation.icon}
label={presentation.label}
tone={presentation.tone}
+ spinning={presentation.spinning}
/>
{remediationSummary?.outcomeSummary ? (
@@ -121,7 +131,7 @@ export function FindingRemediationPanel({
{startRemediation.isPending ? (
) : null}
- Start fix
+ Start remediation
) : null}
@@ -136,7 +146,7 @@ export function FindingRemediationPanel({
{retryRemediation.isPending ? (
) : null}
- Retry fix
+ Retry remediation
) : null}
@@ -168,7 +178,7 @@ export function FindingRemediationPanel({
{cancelRemediation.isPending ? (
) : null}
- Cancel fix
+ Cancel remediation
) : null}
@@ -190,6 +200,10 @@ export function FindingRemediationPanel({
{remediationAttempts.map(attempt => {
@@ -215,6 +229,7 @@ export function FindingRemediationPanel({
icon={attemptPresentation.icon}
label={attemptPresentation.label}
tone={attemptPresentation.tone}
+ spinning={attemptPresentation.spinning}
/>
diff --git a/apps/mobile/src/components/security-agent/finding-row.tsx b/apps/mobile/src/components/security-agent/finding-row.tsx
index 2f8cc09947..f4f7a3c9e7 100644
--- a/apps/mobile/src/components/security-agent/finding-row.tsx
+++ b/apps/mobile/src/components/security-agent/finding-row.tsx
@@ -12,11 +12,12 @@ import {
findingToneColor,
} from '@/components/security-agent/finding-tone';
import { Button } from '@/components/ui/button';
+import { SpinningIcon } from '@/components/ui/spinning-icon';
import { Text } from '@/components/ui/text';
import { useStartSecurityAnalysis } from '@/lib/hooks/use-security-findings';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { getSecurityAgentPath, type SecurityFinding } from '@/lib/security-agent';
-import { cn } from '@/lib/utils';
+import { capitalize, cn } from '@/lib/utils';
const SEVERITY_TEXT_CLASS: Record = {
critical: 'text-destructive',
@@ -25,10 +26,6 @@ const SEVERITY_TEXT_CLASS: Record = {
low: 'text-muted-foreground',
};
-function severityLabel(severity: string): string {
- return severity.length > 0 ? `${severity.charAt(0).toUpperCase()}${severity.slice(1)}` : severity;
-}
-
// Clearest next action for this finding, mirroring the priority order in
// apps/web/src/components/security-agent/SecurityFindingRow.tsx — but as a
// read-only summary label (the list row only navigates; mutations live on
@@ -44,10 +41,10 @@ function getNextActionLabel(finding: SecurityFinding): string | null {
return 'Remediation in progress';
}
if (capability.canRetry) {
- return 'Retry fix available';
+ return 'Retry remediation available';
}
if (capability.canStart) {
- return 'Fix available';
+ return 'Remediation available';
}
const needsAnalysis =
finding.status === 'open' && (!finding.analysis_status || finding.analysis_status === 'failed');
@@ -67,17 +64,23 @@ type FindingRowQuickActionProps = {
finding: SecurityFinding;
scope: string;
prUrl: string | null;
- canQuickAnalyze: boolean;
+ isAnalyzable: boolean;
+ hasAnalysisCapacity: boolean;
nextAction: string | null;
};
-// Extracted to avoid a nested ternary (prUrl / canQuickAnalyze / nextAction
-// fallback) while keeping FindingRow's render body flat.
+// Extracted to avoid a nested ternary (prUrl / isAnalyzable / nextAction
+// fallback) while keeping FindingRow's render body flat. `isAnalyzable`
+// (eligibility) and `hasAnalysisCapacity` (loaded-and-not-full) are kept
+// separate so the Analyze button's presence never depends on capacity still
+// loading — only its disabled state does, avoiding a button/text layout
+// shift once capacity resolves.
function FindingRowQuickAction({
finding,
scope,
prUrl,
- canQuickAnalyze,
+ isAnalyzable,
+ hasAnalysisCapacity,
nextAction,
}: Readonly) {
const colors = useThemeColors();
@@ -89,7 +92,7 @@ function FindingRowQuickAction({
{
void Linking.openURL(prUrl);
}}
@@ -100,13 +103,13 @@ function FindingRowQuickAction({
);
}
- if (canQuickAnalyze) {
+ if (isAnalyzable) {
return (
{
startAnalysis.mutate({
findingId: finding.id,
@@ -137,7 +140,7 @@ type FindingRowProps = {
finding: SecurityFinding;
scope: string;
slaEnabled: boolean;
- /** From useSecurityAnalysisCapacity(scope) — gates the row's quick Analyze action. */
+ /** From useSecurityAnalysisCapacity(scope) — disables (not hides) the row's quick Analyze action. */
hasAnalysisCapacity: boolean;
};
@@ -159,18 +162,20 @@ export function FindingRow({
const prUrl =
finding.remediationSummary?.status === 'pr_opened' ? finding.remediationSummary.prUrl : null;
- const canQuickAnalyze =
+ // Eligibility is independent of capacity — the button stays visible
+ // (disabled via hasAnalysisCapacity) instead of swapping for the
+ // nextAction text once capacity resolves.
+ const isAnalyzable =
!prUrl &&
finding.status === 'open' &&
- (!finding.analysis_status || finding.analysis_status === 'failed') &&
- hasAnalysisCapacity;
+ (!finding.analysis_status || finding.analysis_status === 'failed');
return (
{
router.push(getSecurityAgentPath(scope, `findings/${finding.id}`));
}}
@@ -181,7 +186,7 @@ export function FindingRow({
SEVERITY_TEXT_CLASS[finding.severity] ?? 'text-muted-foreground'
)}
>
- {severityLabel(finding.severity)}
+ {capitalize(finding.severity)}
{finding.title}
@@ -191,14 +196,24 @@ export function FindingRow({
-
+
{analysis.label}
{deadline && DeadlineIcon && (
-
+
{deadline.label}
@@ -210,7 +225,8 @@ export function FindingRow({
finding={finding}
scope={scope}
prUrl={prUrl}
- canQuickAnalyze={canQuickAnalyze}
+ isAnalyzable={isAnalyzable}
+ hasAnalysisCapacity={hasAnalysisCapacity}
nextAction={nextAction}
/>
diff --git a/apps/mobile/src/components/security-agent/finding-status-badge.tsx b/apps/mobile/src/components/security-agent/finding-status-badge.tsx
index e82f1b7700..443b1bc0a0 100644
--- a/apps/mobile/src/components/security-agent/finding-status-badge.tsx
+++ b/apps/mobile/src/components/security-agent/finding-status-badge.tsx
@@ -6,6 +6,7 @@ import {
FINDING_TONE_TEXT_CLASS,
findingToneColor,
} from '@/components/security-agent/finding-tone';
+import { SpinningIcon } from '@/components/ui/spinning-icon';
import { Text } from '@/components/ui/text';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { cn } from '@/lib/utils';
@@ -15,6 +16,8 @@ type FindingStatusBadgeProps = {
label: string;
tone: FindingTone;
size?: number;
+ /** Set from the presentation's `spinning` field for in-progress states (e.g. queued/analyzing). */
+ spinning?: boolean;
};
export function FindingStatusBadge({
@@ -22,13 +25,19 @@ export function FindingStatusBadge({
label,
tone,
size = 14,
+ spinning = false,
}: Readonly) {
const colors = useThemeColors();
const Icon = FINDING_ICONS[icon];
return (
-
+
{label}
);
diff --git a/apps/mobile/src/components/security-agent/notification-settings-screen.tsx b/apps/mobile/src/components/security-agent/notification-settings-screen.tsx
index 5fd8a11c32..45c05a9a04 100644
--- a/apps/mobile/src/components/security-agent/notification-settings-screen.tsx
+++ b/apps/mobile/src/components/security-agent/notification-settings-screen.tsx
@@ -5,27 +5,29 @@ import {
parseDayCount,
} from '@kilocode/app-shared/security-agent';
import { useEffect, useRef, useState } from 'react';
-import { ScrollView, TextInput, View } from 'react-native';
+import { TextInput, View } from 'react-native';
import { PillGroup } from '@/components/security-agent/settings-pill-group';
import { SettingsSaveButton } from '@/components/security-agent/settings-save-button';
import { ToggleRow } from '@/components/security-agent/settings-toggle-row';
+import { PlatformErrorScreen } from '@/components/platform-error-screen';
import { ScreenHeader } from '@/components/screen-header';
-import { QueryError } from '@/components/query-error';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
+import { TabScreenScrollView } from '@/components/tab-screen';
import {
useSecurityAgentSettingsRedirect,
useSettingsBackGuard,
} from '@/lib/hooks/use-settings-back-guard';
import {
useSaveSecurityAgentConfig,
+ useSecurityAgentCapability,
useSecurityAgentConfig,
- useSecurityAgentEditCapability,
useTrackSecurityAgentInteraction,
} from '@/lib/hooks/use-security-agent';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { type SecurityAgentConfig } from '@/lib/security-agent';
+import { cn } from '@/lib/utils';
type NotificationSeverity = SecurityAgentConfig['newFindingNotificationMinSeverity'];
@@ -55,7 +57,7 @@ function NotificationSettingsSkeleton() {
export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string }>) {
const colors = useThemeColors();
- const canManage = useSecurityAgentEditCapability(scope);
+ const canManage = useSecurityAgentCapability(scope).canManage;
const config = useSecurityAgentConfig(scope);
const save = useSaveSecurityAgentConfig(scope);
const trackInteraction = useTrackSecurityAgentInteraction(scope);
@@ -111,13 +113,20 @@ export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string }
trackRef.current({ interaction: 'settings_notifications_viewed' });
}, []);
- const valid = isValidDayCount(slaNotificationWarningDays);
+ // Only validate the lead-time field while the SLA notification feature
+ // that owns it is enabled — once hidden by the toggle, its value can't
+ // block saving. If the field is invalid at the moment it's hidden, fall
+ // back to the last persisted value instead of sending an invalid one.
+ const warningDaysValid = isValidDayCount(slaNotificationWarningDays);
+ const valid = !slaNotificationsEnabled || warningDaysValid;
const patch = {
newFindingNotificationsEnabled,
newFindingNotificationMinSeverity,
slaNotificationsEnabled,
slaNotificationMinSeverity,
- slaNotificationWarningDays,
+ slaNotificationWarningDays: warningDaysValid
+ ? slaNotificationWarningDays
+ : (initialConfigRef.current.slaNotificationWarningDays ?? slaNotificationWarningDays),
};
const dirty =
hydratedRef.current &&
@@ -125,20 +134,19 @@ export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string }
const handleSave = async () => {
await save.mutateAsync(patch);
+ initialConfigRef.current = { ...initialConfigRef.current, ...patch };
};
- const { onBack } = useSettingsBackGuard({ dirty, valid, onSave: handleSave });
+ const { onBack, skipNextGuardRef } = useSettingsBackGuard({ dirty, valid, onSave: handleSave });
if (config.isError && !config.data) {
return (
-
-
- void config.refetch()}
- />
-
+ void config.refetch()}
+ />
);
}
if (config.isLoading || !config.data) {
@@ -160,18 +168,24 @@ export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string }
valid={valid}
pending={save.isPending}
onSave={handleSave}
+ skipNextGuardRef={skipNextGuardRef}
/>
) : undefined
}
/>
-
+ {!canManage && (
+
+ Only organization owners and billing managers can change these settings.
+
+ )}
- New-finding Notification
+ New-finding notification
- SLA Warning Notification
+ SLA warning notification
)}
-
- {!canManage && (
-
- Only organization owners and billing managers can change these settings.
-
- )}
-
+
);
}
diff --git a/apps/mobile/src/components/security-agent/repository-settings-screen.tsx b/apps/mobile/src/components/security-agent/repository-settings-screen.tsx
index 688d288502..b3f1c0d31c 100644
--- a/apps/mobile/src/components/security-agent/repository-settings-screen.tsx
+++ b/apps/mobile/src/components/security-agent/repository-settings-screen.tsx
@@ -1,25 +1,35 @@
-import { getSettingsDirtyState } from '@kilocode/app-shared/security-agent';
-import * as Haptics from 'expo-haptics';
-import { Check, Lock } from 'lucide-react-native';
+import {
+ getSettingsDirtyState,
+ isPersonalSecurityScope,
+} from '@kilocode/app-shared/security-agent';
+import { FolderGit2 } from 'lucide-react-native';
import { useEffect, useRef, useState } from 'react';
-import { Pressable, ScrollView, View } from 'react-native';
+import { View } from 'react-native';
import { SettingsSaveButton } from '@/components/security-agent/settings-save-button';
+import { EmptyState } from '@/components/empty-state';
+import { PlatformErrorScreen } from '@/components/platform-error-screen';
+import { RepoToggleRow } from '@/components/repo-toggle-row';
import { ScreenHeader } from '@/components/screen-header';
import { QueryError } from '@/components/query-error';
+import { Button } from '@/components/ui/button';
+import { ChoiceRow } from '@/components/ui/choice-row';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
+import { TabScreenScrollView } from '@/components/tab-screen';
+import { getGitHubIntegrationUrl } from '@/lib/agent-github-integration';
+import { WEB_BASE_URL } from '@/lib/config';
+import { openExternalUrl } from '@/lib/external-link';
import {
useSecurityAgentSettingsRedirect,
useSettingsBackGuard,
} from '@/lib/hooks/use-settings-back-guard';
import {
useSaveSecurityAgentConfig,
+ useSecurityAgentCapability,
useSecurityAgentConfig,
- useSecurityAgentEditCapability,
useSecurityAgentRepositories,
} from '@/lib/hooks/use-security-agent';
-import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { type SecurityAgentConfig } from '@/lib/security-agent';
type RepositorySelectionMode = SecurityAgentConfig['repositorySelectionMode'];
@@ -39,8 +49,7 @@ function RepositorySettingsSkeleton() {
}
export function RepositorySettingsScreen({ scope }: Readonly<{ scope: string }>) {
- const colors = useThemeColors();
- const canManage = useSecurityAgentEditCapability(scope);
+ const canManage = useSecurityAgentCapability(scope).canManage;
const config = useSecurityAgentConfig(scope);
const repositories = useSecurityAgentRepositories(scope);
const save = useSaveSecurityAgentConfig(scope);
@@ -66,30 +75,26 @@ export function RepositorySettingsScreen({ scope }: Readonly<{ scope: string }>)
useSecurityAgentSettingsRedirect(scope, config.data?.isEnabled);
const valid = mode === 'all' || selectedIds.length > 0;
+ const patch = { repositorySelectionMode: mode, selectedRepositoryIds: selectedIds };
const dirty =
hydratedRef.current &&
- getSettingsDirtyState(
- initialConfigRef.current,
- { repositorySelectionMode: mode, selectedRepositoryIds: selectedIds },
- valid
- ) !== 'clean';
+ getSettingsDirtyState(initialConfigRef.current, patch, valid) !== 'clean';
const handleSave = async () => {
- await save.mutateAsync({ repositorySelectionMode: mode, selectedRepositoryIds: selectedIds });
+ await save.mutateAsync(patch);
+ initialConfigRef.current = { ...initialConfigRef.current, ...patch };
};
- const { onBack } = useSettingsBackGuard({ dirty, valid, onSave: handleSave });
+ const { onBack, skipNextGuardRef } = useSettingsBackGuard({ dirty, valid, onSave: handleSave });
if (config.isError && !config.data) {
return (
-
-
- void config.refetch()}
- />
-
+ void config.refetch()}
+ />
);
}
if (config.isLoading || !config.data) {
@@ -100,12 +105,10 @@ export function RepositorySettingsScreen({ scope }: Readonly<{ scope: string }>)
}
const setModeOption = (option: RepositorySelectionMode) => {
- void Haptics.selectionAsync();
setMode(option);
};
const toggleRepo = (id: number) => {
- void Haptics.selectionAsync();
setSelectedIds(current =>
current.includes(id) ? current.filter(existing => existing !== id) : [...current, id]
);
@@ -123,27 +126,28 @@ export function RepositorySettingsScreen({ scope }: Readonly<{ scope: string }>)
valid={valid}
pending={save.isPending}
onSave={handleSave}
+ skipNextGuardRef={skipNextGuardRef}
/>
) : undefined
}
/>
-
+
+ {!canManage && (
+
+ Only organization owners and billing managers can change these settings.
+
+ )}
{(['all', 'selected'] as const).map(option => (
- {
setModeOption(option);
}}
- accessibilityRole="radio"
- accessibilityState={{ selected: mode === option }}
- >
-
- {option === 'all' ? 'All repositories' : 'Selected repositories'}
-
-
-
+ />
))}
{mode === 'selected' && (
@@ -157,41 +161,64 @@ export function RepositorySettingsScreen({ scope }: Readonly<{ scope: string }>)
)}
- {(repositories.data ?? []).map(repo => (
- {
- toggleRepo(repo.id);
- }}
- accessibilityRole="checkbox"
- accessibilityState={{ checked: selectedIds.includes(repo.id) }}
- >
-
- {repo.private ? : null}
-
- {repo.fullName}
-
-
-
-
- ))}
- {selectedIds.length === 0 && (
- Select at least one repository.
+ {repositories.isError && (
+ void repositories.refetch()}
+ isRetrying={repositories.isFetching}
+ />
)}
+ {!repositories.isLoading && !repositories.isError && repositories.data?.length === 0 ? (
+ {
+ void openExternalUrl(
+ getGitHubIntegrationUrl(
+ WEB_BASE_URL,
+ isPersonalSecurityScope(scope) ? undefined : scope
+ ),
+ { label: 'GitHub App settings' }
+ );
+ }}
+ >
+ Manage GitHub App access
+
+ }
+ />
+ ) : null}
+ {!repositories.isLoading &&
+ !repositories.isError &&
+ (repositories.data ?? []).map(repo => (
+ {
+ toggleRepo(repo.id);
+ }}
+ />
+ ))}
+ {!repositories.isLoading &&
+ !repositories.isError &&
+ (repositories.data?.length ?? 0) > 0 &&
+ selectedIds.length === 0 && (
+
+ Select at least one repository.
+
+ )}
)}
-
- {!canManage && (
-
- Only organization owners and billing managers can change these settings.
-
- )}
-
+
);
}
diff --git a/apps/mobile/src/components/security-agent/scope-entry-screen.tsx b/apps/mobile/src/components/security-agent/scope-entry-screen.tsx
index b3984dd54e..5a793e40fc 100644
--- a/apps/mobile/src/components/security-agent/scope-entry-screen.tsx
+++ b/apps/mobile/src/components/security-agent/scope-entry-screen.tsx
@@ -1,16 +1,18 @@
import { isPersonalSecurityScope } from '@kilocode/app-shared/security-agent';
import { useRouter } from 'expo-router';
import { useEffect } from 'react';
-import { Pressable, View } from 'react-native';
+import { View } from 'react-native';
+import { AuditReportButton } from '@/components/security-agent/audit-report-button';
+import { PlatformErrorScreen } from '@/components/platform-error-screen';
import { ScreenHeader } from '@/components/screen-header';
import { DashboardScreen } from '@/components/security-agent/dashboard-screen';
import { SecurityAgentSetup } from '@/components/security-agent/security-agent-setup';
import { Skeleton } from '@/components/ui/skeleton';
-import { Text } from '@/components/ui/text';
import { getGitHubIntegrationUrl } from '@/lib/agent-github-integration';
import { WEB_BASE_URL } from '@/lib/config';
import {
+ useSecurityAgentCapability,
useSecurityAgentConfig,
useSecurityAgentPermissionStatus,
useSecurityAgentRepositories,
@@ -36,33 +38,15 @@ function ScopeEntrySkeleton() {
);
}
-function ScopeEntryError({ onRetry }: Readonly<{ onRetry: () => void }>) {
- return (
-
-
-
-
-
- Could not load Security Agent. Tap to retry.
-
-
-
-
- );
-}
-
export function ScopeEntryScreen({ scope }: Readonly<{ scope: string }>) {
const router = useRouter();
const permission = useSecurityAgentPermissionStatus(scope);
const config = useSecurityAgentConfig(scope);
const repositories = useSecurityAgentRepositories(scope);
+ const capability = useSecurityAgentCapability(scope);
- const isLoading = permission.isLoading || config.isLoading;
- const isError = permission.isError || config.isError;
+ const isLoading = permission.isLoading || config.isLoading || capability.isLoading;
+ const isError = permission.isError || config.isError || capability.isError;
const hasIntegration = permission.data?.hasIntegration ?? false;
const hasPermissions = permission.data?.hasPermissions ?? false;
@@ -76,7 +60,12 @@ export function ScopeEntryScreen({ scope }: Readonly<{ scope: string }>) {
}, [isDisabled, router, scope]);
const refetchAll = async () => {
- await Promise.all([permission.refetch(), config.refetch(), repositories.refetch()]);
+ await Promise.all([
+ permission.refetch(),
+ config.refetch(),
+ repositories.refetch(),
+ capability.refetch(),
+ ]);
};
if (isLoading) {
@@ -85,19 +74,29 @@ export function ScopeEntryScreen({ scope }: Readonly<{ scope: string }>) {
if (isError) {
return (
- {
void permission.refetch();
void config.refetch();
+ void capability.refetch();
}}
+ isRetrying={permission.isFetching || config.isFetching || capability.isFetching}
/>
);
}
+ // Audit-report access shouldn't depend on the agent being connected or
+ // enabled — it's a link to historical reports, not agent-managed UI — so
+ // it's offered here in the shared shell, ahead of the setup/disabled
+ // gates below, whenever the caller can manage the agent.
+ const auditAction = capability.canManage ? : null;
+
if (!hasIntegration) {
return (
-
+
) {
if (!hasPermissions) {
return (
-
+
{
router.push(getSecurityAgentPath(scope));
};
+ // An org-list failure only degrades the organization section — the
+ // Personal scope doesn't depend on it and must stay reachable.
+ const showOrgsError = isError && !orgs;
+
+ const renderOrgSection = () => {
+ if (showOrgsError) {
+ return (
+ void refetch()}
+ isRetrying={isFetching}
+ className="mt-3"
+ />
+ );
+ }
+ if (isLoading) {
+ return ;
+ }
+ return orgs?.map((org, index) => (
+ {
+ openScope(org.organizationId);
+ }}
+ last={index === orgs.length - 1}
+ />
+ ));
+ };
+
return (
-
+
{
openScope(PERSONAL_SECURITY_SCOPE);
}}
- last={!isLoading && (orgs?.length ?? 0) === 0}
+ last={!isLoading && !showOrgsError && (orgs?.length ?? 0) === 0}
/>
- {isLoading ? (
-
- ) : (
- orgs?.map((org, index) => (
- {
- openScope(org.organizationId);
- }}
- last={index === orgs.length - 1}
- />
- ))
- )}
-
+ {renderOrgSection()}
+
);
}
diff --git a/apps/mobile/src/components/security-agent/security-agent-setup.tsx b/apps/mobile/src/components/security-agent/security-agent-setup.tsx
index 4daa6e17d5..5a28a5d405 100644
--- a/apps/mobile/src/components/security-agent/security-agent-setup.tsx
+++ b/apps/mobile/src/components/security-agent/security-agent-setup.tsx
@@ -6,6 +6,7 @@ import { toast } from 'sonner-native';
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
+import { useTabBarBottomPadding } from '@/components/tab-screen';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
type SecurityAgentSetupProps = {
@@ -25,6 +26,7 @@ export function SecurityAgentSetup({
onConnected,
}: Readonly) {
const colors = useThemeColors();
+ const tabBarPadding = useTabBarBottomPadding();
const [connecting, setConnecting] = useState(false);
const connect = async () => {
@@ -40,7 +42,10 @@ export function SecurityAgentSetup({
};
return (
-
+
{title}
{description}
@@ -51,7 +56,7 @@ export function SecurityAgentSetup({
void connect();
}}
>
- {connecting ? : null}
+ {connecting ? : null}
{buttonLabel}
diff --git a/apps/mobile/src/components/security-agent/settings-overview-screen.tsx b/apps/mobile/src/components/security-agent/settings-overview-screen.tsx
index 7d004717e1..eb92ad9462 100644
--- a/apps/mobile/src/components/security-agent/settings-overview-screen.tsx
+++ b/apps/mobile/src/components/security-agent/settings-overview-screen.tsx
@@ -2,20 +2,23 @@ import * as Haptics from 'expo-haptics';
import { useRouter } from 'expo-router';
import { Bell, Clock, Cpu, FolderGit2, Zap } from 'lucide-react-native';
import { useEffect, useRef } from 'react';
-import { ScrollView, Switch, View } from 'react-native';
+import { Switch, View } from 'react-native';
+import { AuditReportButton } from '@/components/security-agent/audit-report-button';
+import { PlatformErrorScreen } from '@/components/platform-error-screen';
import { ScreenHeader } from '@/components/screen-header';
-import { QueryError } from '@/components/query-error';
import { ConfigureRow } from '@/components/ui/configure-row';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
+import { TabScreenScrollView } from '@/components/tab-screen';
import {
+ useSecurityAgentCapability,
useSecurityAgentConfig,
- useSecurityAgentEditCapability,
useSetSecurityAgentEnabled,
useTrackSecurityAgentInteraction,
} from '@/lib/hooks/use-security-agent';
import { getSecurityAgentPath } from '@/lib/security-agent';
+import { capitalize } from '@/lib/utils';
function SettingsOverviewSkeleton() {
return (
@@ -30,14 +33,10 @@ function SettingsOverviewSkeleton() {
);
}
-function capitalize(value: string) {
- return value.charAt(0).toUpperCase() + value.slice(1);
-}
-
export function SettingsOverviewScreen({ scope }: Readonly<{ scope: string }>) {
const router = useRouter();
const config = useSecurityAgentConfig(scope);
- const canManage = useSecurityAgentEditCapability(scope);
+ const canManage = useSecurityAgentCapability(scope).canManage;
const setEnabled = useSetSecurityAgentEnabled(scope);
const trackInteraction = useTrackSecurityAgentInteraction(scope);
@@ -58,14 +57,12 @@ export function SettingsOverviewScreen({ scope }: Readonly<{ scope: string }>) {
if (config.isError && !config.data) {
return (
-
-
- void config.refetch()}
- />
-
+ void config.refetch()}
+ />
);
}
if (config.isLoading || !config.data) {
@@ -96,10 +93,18 @@ export function SettingsOverviewScreen({ scope }: Readonly<{ scope: string }>) {
});
};
+ // Audit-report access shouldn't depend on the agent being enabled — see
+ // the matching header action in scope-entry-screen.tsx, which reaches
+ // audit reports from the connected-but-disconnected states. This is the
+ // connected-but-disabled counterpart: settings-overview-screen is where
+ // scope-entry redirects once the agent is disabled, so the same action
+ // needs to be reachable here too.
+ const auditAction = canManage ? : null;
+
return (
-
-
+
+
Security Agent
@@ -180,7 +185,7 @@ export function SettingsOverviewScreen({ scope }: Readonly<{ scope: string }>) {
/>
)}
-
+
);
}
diff --git a/apps/mobile/src/components/security-agent/settings-pill-group.tsx b/apps/mobile/src/components/security-agent/settings-pill-group.tsx
index 7eb5dbc5d3..f8b527208f 100644
--- a/apps/mobile/src/components/security-agent/settings-pill-group.tsx
+++ b/apps/mobile/src/components/security-agent/settings-pill-group.tsx
@@ -21,8 +21,9 @@ export function PillGroup({
onChange,
}: Readonly<{
label: string;
- options: { value: T; label: string }[];
- value: T;
+ options: readonly { value: T; label: string }[];
+ /** `null` when nothing is selected yet, e.g. an unset dismissal reason. */
+ value: T | null;
disabled: boolean;
onChange: (value: T) => void;
}>) {
@@ -41,14 +42,15 @@ export function PillGroup({
disabled={disabled}
className={cn(
'min-h-11 flex-row items-center justify-between px-4 py-3 active:opacity-70',
- index < options.length - 1 && 'border-b-[0.5px] border-hair-soft'
+ index < options.length - 1 && 'border-b-[0.5px] border-hair-soft',
+ disabled && 'opacity-50'
)}
onPress={() => {
void Haptics.selectionAsync();
onChange(option.value);
}}
accessibilityRole="radio"
- accessibilityState={{ selected: active }}
+ accessibilityState={{ selected: active, disabled }}
>
Promise;
+ // From useSettingsBackGuard — set right before router.back() so the
+ // back-navigation this button itself triggers doesn't get intercepted as
+ // an unconfirmed exit (see use-settings-back-guard.ts).
+ skipNextGuardRef: RefObject;
}>) {
const router = useRouter();
const colors = useThemeColors();
@@ -36,6 +42,7 @@ export function SettingsSaveButton({
void (async () => {
try {
await onSave();
+ skipNextGuardRef.current = true;
router.back();
} catch {
// Centralized onError already toasted; stay on screen.
diff --git a/apps/mobile/src/components/security-agent/sla-settings-screen.tsx b/apps/mobile/src/components/security-agent/sla-settings-screen.tsx
index ff9e7b035d..22556336a5 100644
--- a/apps/mobile/src/components/security-agent/sla-settings-screen.tsx
+++ b/apps/mobile/src/components/security-agent/sla-settings-screen.tsx
@@ -4,31 +4,33 @@ import {
parseDayCount,
} from '@kilocode/app-shared/security-agent';
import { useEffect, useRef, useState } from 'react';
-import { ScrollView, TextInput, View } from 'react-native';
+import { TextInput, View } from 'react-native';
import { SettingsSaveButton } from '@/components/security-agent/settings-save-button';
import { ToggleRow } from '@/components/security-agent/settings-toggle-row';
+import { PlatformErrorScreen } from '@/components/platform-error-screen';
import { ScreenHeader } from '@/components/screen-header';
-import { QueryError } from '@/components/query-error';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
+import { TabScreenScrollView } from '@/components/tab-screen';
import {
useSecurityAgentSettingsRedirect,
useSettingsBackGuard,
} from '@/lib/hooks/use-settings-back-guard';
import {
useSaveSecurityAgentConfig,
+ useSecurityAgentCapability,
useSecurityAgentConfig,
- useSecurityAgentEditCapability,
useTrackSecurityAgentInteraction,
} from '@/lib/hooks/use-security-agent';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { type SecurityAgentConfig } from '@/lib/security-agent';
+import { cn } from '@/lib/utils';
function SlaSettingsSkeleton() {
return (
-
+
@@ -75,7 +77,12 @@ function SlaDayRow({
const [days, setDays] = useState(initialValue);
return (
-
+
{label}
@@ -93,6 +100,7 @@ function SlaDayRow({
className="h-11 w-16 rounded-lg border border-input bg-background px-2 text-sm leading-5 text-foreground"
textAlign="center"
editable={!disabled}
+ accessibilityState={{ disabled }}
keyboardType="number-pad"
defaultValue={rawRef.current}
placeholderTextColor={colors.mutedForeground}
@@ -108,16 +116,18 @@ function SlaDayRow({
}
export function SlaSettingsScreen({ scope }: Readonly<{ scope: string }>) {
- const canManage = useSecurityAgentEditCapability(scope);
+ const canManage = useSecurityAgentCapability(scope).canManage;
const config = useSecurityAgentConfig(scope);
const save = useSaveSecurityAgentConfig(scope);
const trackInteraction = useTrackSecurityAgentInteraction(scope);
const [slaEnabled, setSlaEnabled] = useState(false);
- const [slaCriticalDays, setSlaCriticalDays] = useState(Number.NaN);
- const [slaHighDays, setSlaHighDays] = useState(Number.NaN);
- const [slaMediumDays, setSlaMediumDays] = useState(Number.NaN);
- const [slaLowDays, setSlaLowDays] = useState(Number.NaN);
+ const [slaDays, setSlaDays] = useState>({
+ critical: Number.NaN,
+ high: Number.NaN,
+ medium: Number.NaN,
+ low: Number.NaN,
+ });
const hydratedRef = useRef(false);
const initialConfigRef = useRef>({});
@@ -133,10 +143,12 @@ export function SlaSettingsScreen({ scope }: Readonly<{ scope: string }>) {
hydratedRef.current = true;
initialConfigRef.current = config.data;
setSlaEnabled(config.data.slaEnabled);
- setSlaCriticalDays(config.data.slaCriticalDays);
- setSlaHighDays(config.data.slaHighDays);
- setSlaMediumDays(config.data.slaMediumDays);
- setSlaLowDays(config.data.slaLowDays);
+ setSlaDays({
+ critical: config.data.slaCriticalDays,
+ high: config.data.slaHighDays,
+ medium: config.data.slaMediumDays,
+ low: config.data.slaLowDays,
+ });
}, [config.data]);
useSecurityAgentSettingsRedirect(scope, config.data?.isEnabled);
@@ -155,17 +167,29 @@ export function SlaSettingsScreen({ scope }: Readonly<{ scope: string }>) {
trackRef.current({ interaction: 'settings_sla_viewed' });
}, []);
- const valid =
- isValidDayCount(slaCriticalDays) &&
- isValidDayCount(slaHighDays) &&
- isValidDayCount(slaMediumDays) &&
- isValidDayCount(slaLowDays);
+ // Only validate the four day fields while SLA tracking is enabled — once
+ // hidden by the toggle, an invalid day count can't block saving. If a
+ // field is invalid at the moment it's hidden, fall back to its last
+ // persisted value instead of sending an invalid one.
+ const daysValid: Record = {
+ critical: isValidDayCount(slaDays.critical),
+ high: isValidDayCount(slaDays.high),
+ medium: isValidDayCount(slaDays.medium),
+ low: isValidDayCount(slaDays.low),
+ };
+ const valid = !slaEnabled || Object.values(daysValid).every(Boolean);
const patch = {
slaEnabled,
- slaCriticalDays,
- slaHighDays,
- slaMediumDays,
- slaLowDays,
+ slaCriticalDays: daysValid.critical
+ ? slaDays.critical
+ : (initialConfigRef.current.slaCriticalDays ?? slaDays.critical),
+ slaHighDays: daysValid.high
+ ? slaDays.high
+ : (initialConfigRef.current.slaHighDays ?? slaDays.high),
+ slaMediumDays: daysValid.medium
+ ? slaDays.medium
+ : (initialConfigRef.current.slaMediumDays ?? slaDays.medium),
+ slaLowDays: daysValid.low ? slaDays.low : (initialConfigRef.current.slaLowDays ?? slaDays.low),
};
const dirty =
hydratedRef.current &&
@@ -173,20 +197,19 @@ export function SlaSettingsScreen({ scope }: Readonly<{ scope: string }>) {
const handleSave = async () => {
await save.mutateAsync(patch);
+ initialConfigRef.current = { ...initialConfigRef.current, ...patch };
};
- const { onBack } = useSettingsBackGuard({ dirty, valid, onSave: handleSave });
+ const { onBack, skipNextGuardRef } = useSettingsBackGuard({ dirty, valid, onSave: handleSave });
if (config.isError && !config.data) {
return (
-
-
- void config.refetch()}
- />
-
+ void config.refetch()}
+ />
);
}
if (config.isLoading || !config.data) {
@@ -196,23 +219,10 @@ export function SlaSettingsScreen({ scope }: Readonly<{ scope: string }>) {
return null;
}
- const setters: Record void> = {
- critical: setSlaCriticalDays,
- high: setSlaHighDays,
- medium: setSlaMediumDays,
- low: setSlaLowDays,
- };
- const values: Record = {
- critical: slaCriticalDays,
- high: slaHighDays,
- medium: slaMediumDays,
- low: slaLowDays,
- };
-
return (
) {
valid={valid}
pending={save.isPending}
onSave={handleSave}
+ skipNextGuardRef={skipNextGuardRef}
/>
) : undefined
}
/>
-
+ {!canManage && (
+
+ Only organization owners and billing managers can change these settings.
+
+ )}
) {
key={row.key}
label={row.label}
description={row.description}
- initialValue={values[row.key]}
+ initialValue={slaDays[row.key]}
disabled={!canManage}
onChangeValue={value => {
- setters[row.key](value);
+ setSlaDays(current => ({ ...current, [row.key]: value }));
}}
/>
))}
)}
-
- {!canManage && (
-
- Only organization owners and billing managers can change these settings.
-
- )}
-
+
);
}
diff --git a/apps/mobile/src/components/sheet-header.tsx b/apps/mobile/src/components/sheet-header.tsx
new file mode 100644
index 0000000000..8dfb69f7b9
--- /dev/null
+++ b/apps/mobile/src/components/sheet-header.tsx
@@ -0,0 +1,27 @@
+import { Pressable, View } from 'react-native';
+
+import { Text } from '@/components/ui/text';
+
+export function SheetHeader({ title, onDone }: { title: string; onDone: () => void }) {
+ return (
+ // collapsable={false}: react-native-screens lays out a formSheet's scroll
+ // view by finding the header at the screen content's subview index 0 — a
+ // flattened header breaks that native pass and the list paints over it.
+
+
+
+ {title}
+
+
+ Done
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/tab-screen.tsx b/apps/mobile/src/components/tab-screen.tsx
new file mode 100644
index 0000000000..8d9b66c680
--- /dev/null
+++ b/apps/mobile/src/components/tab-screen.tsx
@@ -0,0 +1,17 @@
+import { Platform, ScrollView, type ScrollViewProps } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { getTabBarOverlayHeight } from '@/lib/tab-bar-layout';
+
+// FlatList/FlashList screens use this directly for contentContainerStyle.paddingBottom.
+export function useTabBarBottomPadding() {
+ const { bottom } = useSafeAreaInsets();
+ return getTabBarOverlayHeight(bottom, Platform.OS) + 16;
+}
+
+export function TabScreenScrollView({ contentContainerStyle, ...props }: ScrollViewProps) {
+ const paddingBottom = useTabBarBottomPadding();
+ return (
+
+ );
+}
diff --git a/apps/mobile/src/components/ui/action-button.tsx b/apps/mobile/src/components/ui/action-button.tsx
index 7d2156a053..e31bbb46c0 100644
--- a/apps/mobile/src/components/ui/action-button.tsx
+++ b/apps/mobile/src/components/ui/action-button.tsx
@@ -1,5 +1,5 @@
import { type LucideIcon } from 'lucide-react-native';
-import { Pressable, View } from 'react-native';
+import { ActivityIndicator, Pressable, View } from 'react-native';
import { Text } from '@/components/ui/text';
import { type ThemeColors, useThemeColors } from '@/lib/hooks/use-theme-colors';
@@ -13,6 +13,8 @@ type ActionButtonProps = {
tone?: ActionButtonTone;
onPress?: () => void;
disabled?: boolean;
+ /** Mutation in flight for this action: spinner instead of the icon, never the disabled look (P3/P5). */
+ loading?: boolean;
className?: string;
};
@@ -40,20 +42,27 @@ export function ActionButton({
tone = 'neutral',
onPress,
disabled,
+ loading,
className,
}: Readonly) {
const colors = useThemeColors();
+ const isDisabled = Boolean(disabled) || Boolean(loading);
return (
-
+ {loading ? (
+
+ ) : (
+
+ )}
{label}
diff --git a/apps/mobile/src/components/ui/button.tsx b/apps/mobile/src/components/ui/button.tsx
index 05f3730c2d..7103fe92d1 100644
--- a/apps/mobile/src/components/ui/button.tsx
+++ b/apps/mobile/src/components/ui/button.tsx
@@ -1,7 +1,8 @@
import { cva, type VariantProps } from 'class-variance-authority';
-import { Pressable } from 'react-native';
+import { ActivityIndicator, Pressable } from 'react-native';
import { TextClassContext } from '@/components/ui/text';
+import { type ThemeColors, useThemeColors } from '@/lib/hooks/use-theme-colors';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
@@ -18,10 +19,12 @@ const buttonVariants = cva(
'accent-soft': 'bg-accent-soft active:opacity-80 shadow-sm shadow-black/5',
},
size: {
- default: 'h-10 px-4 py-2',
+ // h-11 (44pt) meets the minimum touch target; sm/icon stay compact
+ // visually and reach 44pt via hitSlop instead (see SM_HIT_SLOP below).
+ default: 'h-11 px-4 py-2',
sm: 'h-9 gap-1.5 rounded-md px-3',
lg: 'h-11 rounded-md px-6',
- icon: 'h-10 w-10',
+ icon: 'h-11 w-11',
},
},
defaultVariants: {
@@ -31,6 +34,27 @@ const buttonVariants = cva(
}
);
+// sm is 36pt tall; expand the touchable area by 4pt on every edge to reach 44pt
+// without changing the compact visual size.
+const SM_HIT_SLOP = { top: 4, bottom: 4, left: 4, right: 4 };
+
+// Spinner color per variant, matching that variant's text color (see
+// buttonTextVariants below). accent-soft's foreground isn't in useThemeColors
+// but is identical in both themes (global.css --accent-soft-foreground).
+function spinnerColor(variant: ButtonProps['variant'], colors: ThemeColors): string {
+ if (variant === 'outline' || variant === 'secondary' || variant === 'ghost') {
+ return colors.foreground;
+ }
+ if (variant === 'link') {
+ return colors.primary;
+ }
+ if (variant === 'accent-soft') {
+ return '#1A1A10';
+ }
+ // default, destructive
+ return colors.primaryForeground;
+}
+
const buttonTextVariants = cva('text-foreground text-sm font-semibold', {
variants: {
variant: {
@@ -55,18 +79,40 @@ const buttonTextVariants = cva('text-foreground text-sm font-semibold', {
},
});
-type ButtonProps = React.ComponentProps &
+type ButtonProps = Omit, 'children'> &
React.RefAttributes &
- VariantProps;
+ VariantProps & {
+ /** Disables the button and shows an ActivityIndicator alongside its content. */
+ loading?: boolean;
+ children?: React.ReactNode;
+ };
-function Button({ className, variant, size, ...props }: ButtonProps) {
+function Button({
+ className,
+ variant,
+ size,
+ loading,
+ disabled,
+ accessibilityState,
+ hitSlop,
+ children,
+ ...props
+}: ButtonProps) {
+ const colors = useThemeColors();
+ const isDisabled = Boolean(disabled) || Boolean(loading);
return (
+ >
+ {loading ? : null}
+ {children}
+
);
}
diff --git a/apps/mobile/src/components/ui/choice-row.tsx b/apps/mobile/src/components/ui/choice-row.tsx
new file mode 100644
index 0000000000..93bdf060b3
--- /dev/null
+++ b/apps/mobile/src/components/ui/choice-row.tsx
@@ -0,0 +1,72 @@
+import * as Haptics from 'expo-haptics';
+import { Check } from 'lucide-react-native';
+import { type ReactNode } from 'react';
+import { Pressable, View } from 'react-native';
+
+import { Text } from '@/components/ui/text';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { cn } from '@/lib/utils';
+
+type ChoiceRowProps = {
+ /** Ignored when `children` is given. */
+ label?: string;
+ description?: string;
+ selected: boolean;
+ onPress: () => void;
+ disabled?: boolean;
+ /** Marks the row as busy for a11y, e.g. while its own save is in flight. */
+ busy?: boolean;
+ /** Renders `accessibilityRole="checkbox"` instead of `"radio"`. */
+ multi?: boolean;
+ /** Extra classes on the row container, e.g. a divider border. */
+ className?: string;
+ /** Custom row content instead of the default label/description text — e.g. an icon-prefixed row. */
+ children?: ReactNode;
+};
+
+/**
+ * Shared radio/checkbox-style selection row. Owns the selection haptic —
+ * callers must NOT fire their own `Haptics.selectionAsync()` on press.
+ */
+export function ChoiceRow({
+ label,
+ description,
+ selected,
+ onPress,
+ disabled,
+ busy,
+ multi,
+ className,
+ children,
+}: Readonly) {
+ const colors = useThemeColors();
+
+ return (
+ {
+ void Haptics.selectionAsync();
+ onPress();
+ }}
+ accessibilityRole={multi ? 'checkbox' : 'radio'}
+ accessibilityState={{ checked: selected, disabled: Boolean(disabled), busy }}
+ >
+ {children ?? (
+
+ {label}
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+ )}
+
+
+ );
+}
diff --git a/apps/mobile/src/components/ui/configure-row.tsx b/apps/mobile/src/components/ui/configure-row.tsx
index 6797262094..a0f71d6e83 100644
--- a/apps/mobile/src/components/ui/configure-row.tsx
+++ b/apps/mobile/src/components/ui/configure-row.tsx
@@ -40,12 +40,17 @@ export function ConfigureRow({
const colors = useThemeColors();
const tint: Tint = tone ? toneColor(tone) : agentColor(title);
const iconColor = colors[tint.hueThemeKey];
+ // Inert rows (no onPress) and disabled rows are not tappable — hide the
+ // chevron so they don't look tappable, and never render pressed feedback.
+ const showChevron = Boolean(onPress) && !disabled;
const inner = (
@@ -62,7 +67,7 @@ export function ConfigureRow({
{title}
{subtitle ? {subtitle} : null}
- {trailing ?? }
+ {trailing ?? (showChevron ? : null)}
);
@@ -71,8 +76,8 @@ export function ConfigureRow({
{inner}
diff --git a/apps/mobile/src/components/ui/form-field.tsx b/apps/mobile/src/components/ui/form-field.tsx
new file mode 100644
index 0000000000..1d9fa6242b
--- /dev/null
+++ b/apps/mobile/src/components/ui/form-field.tsx
@@ -0,0 +1,85 @@
+import { useRef, useState } from 'react';
+import { TextInput, type TextInputProps, View } from 'react-native';
+
+import { Text } from '@/components/ui/text';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { cn } from '@/lib/utils';
+
+type FormFieldProps = Omit &
+ React.RefAttributes & {
+ label: string;
+ error?: string;
+ disabled?: boolean;
+ /**
+ * Owns blur-validation: runs on blur, and re-runs live once an error is
+ * showing so it clears the moment the value becomes valid again. When
+ * set, this replaces `error` as the source of the displayed message.
+ */
+ validate?: (value: string) => string | null;
+ };
+
+/**
+ * Uncontrolled text field: visible label, destructive error text, disabled
+ * styling, and a focus-visible border. Never pass a controlled `value` —
+ * use `defaultValue` + `onChangeText` writing to a ref (see CLAUDE.md).
+ */
+function FormField({
+ label,
+ error,
+ disabled,
+ className,
+ ref,
+ validate,
+ defaultValue,
+ onChangeText,
+ onBlur,
+ ...props
+}: Readonly) {
+ const colors = useThemeColors();
+ const [validationError, setValidationError] = useState(null);
+ const valueRef = useRef(typeof defaultValue === 'string' ? defaultValue : '');
+ const displayedError = validate ? validationError : error;
+
+ return (
+
+ {label}
+ {
+ valueRef.current = value;
+ onChangeText?.(value);
+ if (validate && validationError) {
+ setValidationError(validate(value));
+ }
+ }}
+ onBlur={event => {
+ onBlur?.(event);
+ if (validate) {
+ setValidationError(validate(valueRef.current));
+ }
+ }}
+ className={cn(
+ 'rounded-md border border-input bg-background px-3 py-2.5 text-sm leading-5 text-foreground',
+ 'focus:border-ring',
+ displayedError && 'border-destructive',
+ disabled && 'opacity-50',
+ className
+ )}
+ />
+ {displayedError ? (
+
+ {displayedError}
+
+ ) : null}
+
+ );
+}
+
+export { FormField };
+export type { FormFieldProps };
diff --git a/apps/mobile/src/components/ui/kv-row.tsx b/apps/mobile/src/components/ui/kv-row.tsx
index 5aaae30a98..323d66836d 100644
--- a/apps/mobile/src/components/ui/kv-row.tsx
+++ b/apps/mobile/src/components/ui/kv-row.tsx
@@ -56,20 +56,24 @@ export function KvRow({
return (
-
+
{dotTone ? : null}
{Icon ? : null}
- {label}
+
+ {label}
+
{value}
diff --git a/apps/mobile/src/components/ui/session-row.tsx b/apps/mobile/src/components/ui/session-row.tsx
index b806cf4981..168fd9b6f8 100644
--- a/apps/mobile/src/components/ui/session-row.tsx
+++ b/apps/mobile/src/components/ui/session-row.tsx
@@ -77,7 +77,7 @@ export function SessionRow({
{agentLabel}
- {live && }
+ {live && }
{!live && meta && (
{meta}
diff --git a/apps/mobile/src/components/ui/skeleton.tsx b/apps/mobile/src/components/ui/skeleton.tsx
index 4b4f47b85c..adb93f8dd3 100644
--- a/apps/mobile/src/components/ui/skeleton.tsx
+++ b/apps/mobile/src/components/ui/skeleton.tsx
@@ -1,6 +1,8 @@
import { useEffect } from 'react';
import Animated, {
+ cancelAnimation,
useAnimatedStyle,
+ useReducedMotion,
useSharedValue,
withRepeat,
withTiming,
@@ -13,14 +15,22 @@ type SkeletonProps = {
};
export function Skeleton({ className }: Readonly) {
+ const reducedMotion = useReducedMotion();
const opacity = useSharedValue(0.4);
useEffect(() => {
- opacity.value = withRepeat(withTiming(1, { duration: 1000 }), -1, true);
- }, [opacity]);
+ if (!reducedMotion) {
+ opacity.value = withRepeat(withTiming(1, { duration: 1000 }), -1, true);
+ }
+
+ return () => {
+ cancelAnimation(opacity);
+ };
+ }, [opacity, reducedMotion]);
const animatedStyle = useAnimatedStyle(() => ({
- opacity: opacity.value,
+ // Static muted block when reduced motion is on — no shimmer loop.
+ opacity: reducedMotion ? 0.7 : opacity.value,
}));
return ;
diff --git a/apps/mobile/src/components/ui/spinning-icon.tsx b/apps/mobile/src/components/ui/spinning-icon.tsx
new file mode 100644
index 0000000000..4eadb74629
--- /dev/null
+++ b/apps/mobile/src/components/ui/spinning-icon.tsx
@@ -0,0 +1,54 @@
+import { type LucideIcon } from 'lucide-react-native';
+import { useEffect } from 'react';
+import Animated, {
+ cancelAnimation,
+ Easing,
+ useAnimatedStyle,
+ useReducedMotion,
+ useSharedValue,
+ withRepeat,
+ withTiming,
+} from 'react-native-reanimated';
+
+type SpinningIconProps = {
+ icon: LucideIcon;
+ size: number;
+ color: string;
+ /** Whether the icon should currently be rotating. Defaults to true. */
+ spinning?: boolean;
+};
+
+/** Lucide icon with an infinite-rotate loop, honoring the OS reduced-motion setting. */
+export function SpinningIcon({
+ icon: Icon,
+ size,
+ color,
+ spinning = true,
+}: Readonly) {
+ const reducedMotion = useReducedMotion();
+ const isAnimating = spinning && !reducedMotion;
+ const rotation = useSharedValue(0);
+
+ useEffect(() => {
+ if (isAnimating) {
+ rotation.value = withRepeat(withTiming(360, { duration: 1000, easing: Easing.linear }), -1);
+ } else {
+ cancelAnimation(rotation);
+ rotation.value = 0;
+ }
+
+ return () => {
+ cancelAnimation(rotation);
+ };
+ }, [isAnimating, rotation]);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [{ rotate: `${rotation.value}deg` }],
+ }));
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/ui/status-dot.tsx b/apps/mobile/src/components/ui/status-dot.tsx
index f4b3e6c49d..87b2cab109 100644
--- a/apps/mobile/src/components/ui/status-dot.tsx
+++ b/apps/mobile/src/components/ui/status-dot.tsx
@@ -7,28 +7,24 @@ export type StatusDotTone = 'good' | 'warn' | 'danger' | 'muted';
type StatusDotProps = {
tone?: StatusDotTone;
className?: string;
- glow?: boolean;
};
-// Solid inner-dot and outer-halo classes per tone.
-// The halo uses a fixed Tailwind color at 15% alpha because `/opacity`
-// does not work with our CSS-variable theme tokens.
+// Solid inner-dot and outer-halo classes per tone. The halo uses the
+// pre-tinted Focus tile tokens because `/opacity` does not work with our
+// CSS-variable theme tokens.
const TONE: Record = {
- good: { dot: 'bg-good', halo: 'bg-emerald-500/20' },
- warn: { dot: 'bg-warn', halo: 'bg-amber-500/20' },
- danger: { dot: 'bg-destructive', halo: 'bg-red-500/20' },
+ good: { dot: 'bg-good', halo: 'bg-good-tile-bg' },
+ warn: { dot: 'bg-warn', halo: 'bg-warn-tile-bg' },
+ danger: { dot: 'bg-destructive', halo: 'bg-danger-tile-bg' },
muted: { dot: 'bg-muted-soft', halo: 'bg-neutral-500/20' },
};
/**
- * Status indicator dot with an optional halo (replaces CSS box-shadow).
+ * Status indicator dot with a halo (replaces CSS box-shadow).
* 7px inner dot centered inside a 13px halo.
*/
-export function StatusDot({ tone = 'good', glow = true, className }: Readonly) {
+export function StatusDot({ tone = 'good', className }: Readonly) {
const styles = TONE[tone];
- if (!glow) {
- return ;
- }
return (
void;
removeAttachment: (id: string) => void;
+ retryAttachment: (id: string) => void;
reset: () => void;
isUploading: boolean;
+ hasFailedAttachments: boolean;
toWirePayload: () => AgentAttachmentWire | undefined;
};
@@ -127,7 +129,7 @@ export function useAgentAttachmentUpload(
};
const run = async () => {
- update({ status: 'uploading' });
+ update({ status: 'uploading', error: undefined });
try {
const { key } = await uploadOne({
organizationId,
@@ -212,6 +214,17 @@ export function useAgentAttachmentUpload(
setAttachments(current => current.filter(item => item.id !== id));
}, []);
+ const retryAttachment = useCallback(
+ (id: string) => {
+ const attachment = attachments.find(item => item.id === id);
+ if (!attachment) {
+ return;
+ }
+ startUpload(attachment, pathRef.current);
+ },
+ [attachments, startUpload]
+ );
+
const reset = useCallback(() => {
setAttachments([]);
pathRef.current = Crypto.randomUUID();
@@ -231,16 +244,28 @@ export function useAgentAttachmentUpload(
const isUploading = attachments.some(
item => item.status === 'pending' || item.status === 'uploading'
);
+ const failedAttachments = attachments.some(item => item.status === 'error');
return useMemo(
() => ({
attachments,
addCandidates,
removeAttachment,
+ retryAttachment,
reset,
isUploading,
+ hasFailedAttachments: failedAttachments,
toWirePayload,
}),
- [attachments, addCandidates, removeAttachment, reset, isUploading, toWirePayload]
+ [
+ attachments,
+ addCandidates,
+ removeAttachment,
+ retryAttachment,
+ reset,
+ isUploading,
+ failedAttachments,
+ toWirePayload,
+ ]
);
}
diff --git a/apps/mobile/src/lib/auth/poll-response.test.ts b/apps/mobile/src/lib/auth/poll-response.test.ts
new file mode 100644
index 0000000000..efc7672715
--- /dev/null
+++ b/apps/mobile/src/lib/auth/poll-response.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest';
+
+import { classifyPollResponse } from '@/lib/auth/poll-response';
+
+describe('classifyPollResponse', () => {
+ it('resolves on 200 (approved)', () => {
+ expect(classifyPollResponse(200)).toEqual({ status: 'approved' });
+ });
+
+ it('continues polling on 202 (pending)', () => {
+ expect(classifyPollResponse(202)).toEqual({ status: 'pending' });
+ });
+
+ it('treats 403 as a terminal denial', () => {
+ expect(classifyPollResponse(403)).toEqual({
+ status: 'denied',
+ message: 'Access denied by user',
+ });
+ });
+
+ it('treats 410 as a terminal expiry', () => {
+ expect(classifyPollResponse(410)).toEqual({
+ status: 'expired',
+ message: 'Code expired',
+ });
+ });
+
+ it.each([100, 301, 400, 401])(
+ 'treats %i as a terminal error (including 1xx/3xx this endpoint never returns)',
+ httpStatus => {
+ const outcome = classifyPollResponse(httpStatus);
+ expect(outcome.status).toBe('error');
+ }
+ );
+
+ it.each([429, 500, 503])('retries with backoff on %i', httpStatus => {
+ expect(classifyPollResponse(httpStatus)).toEqual({ status: 'retry' });
+ });
+});
diff --git a/apps/mobile/src/lib/auth/poll-response.ts b/apps/mobile/src/lib/auth/poll-response.ts
new file mode 100644
index 0000000000..a82b64b6ef
--- /dev/null
+++ b/apps/mobile/src/lib/auth/poll-response.ts
@@ -0,0 +1,31 @@
+// Pure classification of a device-auth poll response's HTTP status. Kept
+// free of any react-native/expo imports so it can be unit tested directly.
+type PollOutcome =
+ | { readonly status: 'approved' }
+ | { readonly status: 'pending' }
+ | { readonly status: 'denied'; readonly message: string }
+ | { readonly status: 'expired'; readonly message: string }
+ | { readonly status: 'error'; readonly message: string }
+ | { readonly status: 'retry' };
+
+export function classifyPollResponse(httpStatus: number): PollOutcome {
+ if (httpStatus === 200) {
+ return { status: 'approved' };
+ }
+ if (httpStatus === 202) {
+ return { status: 'pending' };
+ }
+ if (httpStatus === 403) {
+ return { status: 'denied', message: 'Access denied by user' };
+ }
+ if (httpStatus === 410) {
+ return { status: 'expired', message: 'Code expired' };
+ }
+ // 429/5xx are transient (rate limiting or a flaky server) — keep polling.
+ if (httpStatus === 429 || httpStatus >= 500) {
+ return { status: 'retry' };
+ }
+ // Any other 4xx (400, 401, ...) is not something retrying will fix — and
+ // 1xx/3xx are statuses this endpoint never returns, so treat them the same.
+ return { status: 'error', message: 'Sign-in failed. Please try again.' };
+}
diff --git a/apps/mobile/src/lib/auth/use-device-auth.ts b/apps/mobile/src/lib/auth/use-device-auth.ts
index 71b085b30c..6cac5eb3dc 100644
--- a/apps/mobile/src/lib/auth/use-device-auth.ts
+++ b/apps/mobile/src/lib/auth/use-device-auth.ts
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { Platform } from 'react-native';
import { API_BASE_URL, WEB_BASE_URL } from '@/lib/config';
+import { classifyPollResponse } from '@/lib/auth/poll-response';
type DeviceAuthStatus = 'idle' | 'pending' | 'approved' | 'denied' | 'expired' | 'error';
@@ -20,7 +21,15 @@ type DeviceAuthResult = DeviceAuthState & {
openBrowser: () => Promise;
};
-const POLL_INTERVAL_MS = 3000;
+const POLL_BASE_INTERVAL_MS = 3000;
+const POLL_MAX_INTERVAL_MS = 15_000;
+// Safety net in case the server never returns a terminal status (200/403/410) —
+// without this a dropped code would poll forever.
+const POLL_OVERALL_TIMEOUT_MS = 5 * 60 * 1000;
+// expo/RN's Hermes build bundled with this Expo SDK does not reliably expose
+// AbortSignal.timeout, so we use the AbortController + setTimeout pattern
+// instead of relying on it.
+const START_TIMEOUT_MS = 15_000;
// Android has no native auth session; expo-web-browser's polyfill keeps
// module-level state that can get stuck and reject every future call
@@ -41,13 +50,13 @@ export function useDeviceAuth(): DeviceAuthResult {
verificationUrl: undefined,
});
- const intervalReference = useRef | undefined>(undefined);
+ const timeoutReference = useRef | undefined>(undefined);
const abortReference = useRef(undefined);
const cleanup = useCallback(() => {
- if (intervalReference.current) {
- clearInterval(intervalReference.current);
- intervalReference.current = undefined;
+ if (timeoutReference.current) {
+ clearTimeout(timeoutReference.current);
+ timeoutReference.current = undefined;
}
if (abortReference.current) {
abortReference.current.abort();
@@ -60,14 +69,36 @@ export function useDeviceAuth(): DeviceAuthResult {
const poll = useCallback(
(code: string, abort: AbortController) => {
+ const startedAt = Date.now();
+ let retryDelay = POLL_BASE_INTERVAL_MS;
+
+ const scheduleNext = (delay: number) => {
+ timeoutReference.current = setTimeout(() => {
+ void tick();
+ }, delay);
+ };
+
const tick = async () => {
+ if (Date.now() - startedAt > POLL_OVERALL_TIMEOUT_MS) {
+ cleanup();
+ setState(previous => ({
+ status: 'error',
+ code,
+ token: undefined,
+ error: 'Sign-in timed out. Please try again.',
+ verificationUrl: previous.verificationUrl,
+ }));
+ return;
+ }
+
try {
const response = await fetch(`${API_BASE_URL}/api/device-auth/codes/${code}`, {
signal: abort.signal,
});
+ const outcome = classifyPollResponse(response.status);
- switch (response.status) {
- case 200: {
+ switch (outcome.status) {
+ case 'approved': {
const data = (await response.json()) as { token: string };
cleanup();
// dismissAuthSession closes the iOS ASWebAuthenticationSession sheet. On
@@ -83,33 +114,33 @@ export function useDeviceAuth(): DeviceAuthResult {
error: undefined,
verificationUrl: previous.verificationUrl,
}));
- break;
+ return;
}
- case 403: {
+ case 'denied':
+ case 'expired':
+ case 'error': {
cleanup();
setState(previous => ({
- status: 'denied',
+ status: outcome.status,
code,
token: undefined,
- error: 'Access denied by user',
+ error: outcome.message,
verificationUrl: previous.verificationUrl,
}));
- break;
+ return;
}
- case 410: {
- cleanup();
- setState(previous => ({
- status: 'expired',
- code,
- token: undefined,
- error: 'Code expired',
- verificationUrl: previous.verificationUrl,
- }));
+ case 'retry': {
+ retryDelay = Math.min(retryDelay * 2, POLL_MAX_INTERVAL_MS);
+ scheduleNext(retryDelay);
+ return;
+ }
+ case 'pending': {
+ retryDelay = POLL_BASE_INTERVAL_MS;
+ scheduleNext(retryDelay);
break;
}
// No default
}
- // 202 = still pending, continue polling
} catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') {
return;
@@ -125,9 +156,7 @@ export function useDeviceAuth(): DeviceAuthResult {
}
};
- intervalReference.current = setInterval(() => {
- void tick();
- }, POLL_INTERVAL_MS);
+ scheduleNext(retryDelay);
},
[cleanup]
);
@@ -143,11 +172,39 @@ export function useDeviceAuth(): DeviceAuthResult {
verificationUrl: undefined,
});
+ // Held in abortReference so cancel() can abort the in-flight POST too —
+ // otherwise a request resolving after Cancel would overwrite the idle
+ // state, start polling, and open the browser anyway.
+ const startAbort = new AbortController();
+ abortReference.current = startAbort;
+ const startTimeout = setTimeout(() => {
+ startAbort.abort();
+ setState({
+ status: 'error',
+ code: undefined,
+ token: undefined,
+ error: 'Failed to start sign in. Please try again.',
+ verificationUrl: undefined,
+ });
+ }, START_TIMEOUT_MS);
+
try {
const response = await fetch(`${API_BASE_URL}/api/device-auth/codes?app=1`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
+ signal: startAbort.signal,
});
+ // The timeout guards ONLY the POST. This function stays suspended on
+ // `await openAuthBrowser(...)` for as long as the auth sheet is open,
+ // so a timer still running past this point would fire mid-sign-in and
+ // stomp the live pending/idle state with a bogus error.
+ clearTimeout(startTimeout);
+
+ // Cancel can race request completion — if it landed while awaiting,
+ // the user is back on the idle screen; don't revive the flow.
+ if (startAbort.signal.aborted) {
+ return;
+ }
if (!response.ok) {
setState({
@@ -191,7 +248,13 @@ export function useDeviceAuth(): DeviceAuthResult {
poll(data.code, abort);
await openAuthBrowser(browserUrl);
- } catch {
+ } catch (error: unknown) {
+ // An aborted POST is either the 15s start timeout (its callback set
+ // the error state already) or an explicit cancel (stays idle) —
+ // either way there's nothing more to do here.
+ if (error instanceof Error && error.name === 'AbortError') {
+ return;
+ }
setState({
status: 'error',
code: undefined,
@@ -199,6 +262,8 @@ export function useDeviceAuth(): DeviceAuthResult {
error: 'Failed to start sign in. Please try again.',
verificationUrl: undefined,
});
+ } finally {
+ clearTimeout(startTimeout);
}
},
[cleanup, poll]
diff --git a/apps/mobile/src/lib/auth/use-native-auth.ts b/apps/mobile/src/lib/auth/use-native-auth.ts
index 0bb6620e9a..76a011f013 100644
--- a/apps/mobile/src/lib/auth/use-native-auth.ts
+++ b/apps/mobile/src/lib/auth/use-native-auth.ts
@@ -35,7 +35,7 @@ function mapError(errorCode: string | undefined): string {
return (errorCode && AUTH_ERROR_MESSAGES[errorCode]) ?? DEFAULT_ERROR_MESSAGE;
}
-// ponytail: only the callers we have need the error code + parsed body; a generic
+// Only the callers we have need the error code + parsed body; a generic
// fetch client would be speculative for two endpoints.
async function postAuth(
path: string,
@@ -74,7 +74,7 @@ function hasStringCode(error: unknown): error is { code: string } {
);
}
-// ponytail: module-level guard — GoogleSignin.configure() is cheap but re-calling it
+// Module-level guard — GoogleSignin.configure() is cheap but re-calling it
// on every button press is pointless; upgrade to a re-configure path if client IDs
// ever need to change at runtime.
let googleSignInConfigured = false;
diff --git a/apps/mobile/src/lib/code-reviewer-config.test.ts b/apps/mobile/src/lib/code-reviewer-config.test.ts
new file mode 100644
index 0000000000..8eb700eebd
--- /dev/null
+++ b/apps/mobile/src/lib/code-reviewer-config.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest';
+
+import { parseReviewerPlatform, PERSONAL_SCOPE } from './code-reviewer-config';
+
+describe('parseReviewerPlatform', () => {
+ it('allows every platform for an organization scope', () => {
+ expect(parseReviewerPlatform('org-1', 'github')).toBe('github');
+ expect(parseReviewerPlatform('org-1', 'gitlab')).toBe('gitlab');
+ expect(parseReviewerPlatform('org-1', 'bitbucket')).toBe('bitbucket');
+ });
+
+ it('allows github and gitlab for the personal scope', () => {
+ expect(parseReviewerPlatform(PERSONAL_SCOPE, 'github')).toBe('github');
+ expect(parseReviewerPlatform(PERSONAL_SCOPE, 'gitlab')).toBe('gitlab');
+ });
+
+ it('rejects bitbucket for the personal scope (org-only platform)', () => {
+ expect(parseReviewerPlatform(PERSONAL_SCOPE, 'bitbucket')).toBeNull();
+ });
+
+ it('rejects an unknown platform', () => {
+ expect(parseReviewerPlatform('org-1', 'gitea')).toBeNull();
+ expect(parseReviewerPlatform(PERSONAL_SCOPE, 'gitea')).toBeNull();
+ });
+
+ it('rejects a missing or repeated route segment', () => {
+ expect(parseReviewerPlatform('org-1', undefined)).toBeNull();
+ expect(parseReviewerPlatform('org-1', ['github', 'gitlab'])).toBeNull();
+ });
+});
diff --git a/apps/mobile/src/lib/code-reviewer-config.ts b/apps/mobile/src/lib/code-reviewer-config.ts
index 42d12e2176..4f9e184d61 100644
--- a/apps/mobile/src/lib/code-reviewer-config.ts
+++ b/apps/mobile/src/lib/code-reviewer-config.ts
@@ -1,5 +1,7 @@
import { type CodeReviewPlatform } from '@kilocode/app-shared/code-review';
+import { parseParam } from '@/lib/route-params';
+
export {
buildSaveConfigInput,
GATE_THRESHOLDS,
@@ -9,9 +11,7 @@ export {
export type ReviewerPlatform = CodeReviewPlatform;
-export function asReviewerPlatform(value: string): ReviewerPlatform {
- return value === 'gitlab' || value === 'bitbucket' ? value : 'github';
-}
+export const PERSONAL_SCOPE = 'personal';
export const PLATFORM_CAPABILITIES: Record<
ReviewerPlatform,
@@ -50,6 +50,28 @@ export const PLATFORM_CAPABILITIES: Record<
},
};
+const REVIEWER_PLATFORMS = Object.keys(PLATFORM_CAPABILITIES) as ReviewerPlatform[];
+
+/**
+ * Strictly parses a route's platform segment against the supported
+ * scope+platform combinations. Replaces the old `asReviewerPlatform`
+ * coercion, which silently fell back to `'github'` for any unrecognized
+ * value — so a malformed deep link (e.g. a personal-scope route to
+ * Bitbucket, which is org-only per PLATFORM_CAPABILITIES) could end up
+ * reading/mutating a different platform's config than the URL claimed.
+ * Returns `null` for an unknown platform or an unsupported combination.
+ */
+export function parseReviewerPlatform(
+ scope: string,
+ rawPlatform: string | string[] | undefined
+): ReviewerPlatform | null {
+ const platform = parseParam(rawPlatform, REVIEWER_PLATFORMS);
+ if (platform && PLATFORM_CAPABILITIES[platform].scopes === 'org' && scope === PERSONAL_SCOPE) {
+ return null;
+ }
+ return platform;
+}
+
export type ReviewConfigData = {
isEnabled: boolean;
reviewStyle: 'strict' | 'balanced' | 'lenient' | 'roast';
diff --git a/apps/mobile/src/lib/code-reviewer-status.test.ts b/apps/mobile/src/lib/code-reviewer-status.test.ts
new file mode 100644
index 0000000000..488c2c6324
--- /dev/null
+++ b/apps/mobile/src/lib/code-reviewer-status.test.ts
@@ -0,0 +1,158 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { classifyPermission, classifyProviderState } from './code-reviewer-status';
+
+describe('classifyProviderState', () => {
+ it('is loading while the query is loading', () => {
+ expect(
+ classifyProviderState({
+ isLoading: true,
+ isError: false,
+ isFetching: true,
+ connected: undefined,
+ hasData: false,
+ refetch: vi.fn(),
+ })
+ ).toEqual({ status: 'loading' });
+ });
+
+ it('is an error on initial-load failure (no cached data)', () => {
+ const refetch = vi.fn();
+ const result = classifyProviderState({
+ isLoading: false,
+ isError: true,
+ isFetching: false,
+ connected: undefined,
+ hasData: false,
+ refetch,
+ });
+ expect(result.status).toBe('error');
+ if (result.status === 'error') {
+ result.refetch();
+ expect(refetch).toHaveBeenCalledTimes(1);
+ }
+ });
+
+ it('does not error when a refetch fails but stale data is still present', () => {
+ expect(
+ classifyProviderState({
+ isLoading: false,
+ isError: true,
+ isFetching: false,
+ connected: true,
+ hasData: true,
+ refetch: vi.fn(),
+ })
+ ).toEqual({ status: 'connected' });
+ });
+
+ it('is connected when the query resolves connected: true', () => {
+ expect(
+ classifyProviderState({
+ isLoading: false,
+ isError: false,
+ isFetching: false,
+ connected: true,
+ hasData: true,
+ refetch: vi.fn(),
+ })
+ ).toEqual({ status: 'connected' });
+ });
+
+ it('is disconnected when the query resolves connected: false', () => {
+ expect(
+ classifyProviderState({
+ isLoading: false,
+ isError: false,
+ isFetching: false,
+ connected: false,
+ hasData: true,
+ refetch: vi.fn(),
+ })
+ ).toEqual({ status: 'disconnected' });
+ });
+});
+
+describe('classifyPermission', () => {
+ it('is always ready+editable for a personal scope, regardless of query state', () => {
+ expect(
+ classifyPermission({
+ isPersonal: true,
+ isLoading: true,
+ isError: true,
+ isFetching: true,
+ role: undefined,
+ refetch: vi.fn(),
+ })
+ ).toEqual({ status: 'ready', canEdit: true });
+ });
+
+ it('is loading while the org list query is loading', () => {
+ expect(
+ classifyPermission({
+ isPersonal: false,
+ isLoading: true,
+ isError: false,
+ isFetching: true,
+ role: undefined,
+ refetch: vi.fn(),
+ })
+ ).toEqual({ status: 'loading' });
+ });
+
+ it('is an error when the org list query fails', () => {
+ const refetch = vi.fn();
+ const result = classifyPermission({
+ isPersonal: false,
+ isLoading: false,
+ isError: true,
+ isFetching: false,
+ role: undefined,
+ refetch,
+ });
+ expect(result.status).toBe('error');
+ if (result.status === 'error') {
+ result.refetch();
+ expect(refetch).toHaveBeenCalledTimes(1);
+ }
+ });
+
+ it('grants edit for owner and billing_manager roles once loaded', () => {
+ for (const role of ['owner', 'billing_manager']) {
+ expect(
+ classifyPermission({
+ isPersonal: false,
+ isLoading: false,
+ isError: false,
+ isFetching: false,
+ role,
+ refetch: vi.fn(),
+ })
+ ).toEqual({ status: 'ready', canEdit: true });
+ }
+ });
+
+ it('denies edit for member role or an unresolved org', () => {
+ expect(
+ classifyPermission({
+ isPersonal: false,
+ isLoading: false,
+ isError: false,
+ isFetching: false,
+ role: 'member',
+ refetch: vi.fn(),
+ })
+ ).toEqual({ status: 'ready', canEdit: false });
+
+ expect(
+ classifyPermission({
+ isPersonal: false,
+ isLoading: false,
+ isError: false,
+ isFetching: false,
+ role: undefined,
+ refetch: vi.fn(),
+ })
+ ).toEqual({ status: 'ready', canEdit: false });
+ });
+});
diff --git a/apps/mobile/src/lib/code-reviewer-status.ts b/apps/mobile/src/lib/code-reviewer-status.ts
new file mode 100644
index 0000000000..6890642a9e
--- /dev/null
+++ b/apps/mobile/src/lib/code-reviewer-status.ts
@@ -0,0 +1,66 @@
+import { canManageOrganizationBilling } from '@kilocode/app-shared/organizations';
+
+// Pure classification logic for the code-reviewer domain's async query
+// states. Kept dependency-free (no react-query/expo-router/react-native)
+// so it can be unit-tested directly — the hooks that call these live in
+// use-reviewer-permission.ts.
+
+// Discriminated status for a connect-state query (GitHub/GitLab status,
+// Bitbucket readiness) so screens can tell "still loading", "failed to
+// load" (with retry) and "loaded, just not connected" apart instead of
+// treating every non-connected state as the same "show the connect card"
+// case — a query failure on an already-connected account must not render
+// the connect card.
+type ProviderState =
+ | { status: 'loading' }
+ | { status: 'error'; refetch: () => void; isRetrying: boolean }
+ | { status: 'connected' }
+ | { status: 'disconnected' };
+
+export function classifyProviderState(input: {
+ isLoading: boolean;
+ isError: boolean;
+ isFetching: boolean;
+ connected: boolean | undefined;
+ hasData: boolean;
+ refetch: () => unknown;
+}): ProviderState {
+ if (input.isLoading) {
+ return { status: 'loading' };
+ }
+ // A background refetch failure with stale data present must not blank
+ // out an already-connected screen — only an initial-load failure (no
+ // cached data yet) is a hard error.
+ if (input.isError && !input.hasData) {
+ return { status: 'error', refetch: () => void input.refetch(), isRetrying: input.isFetching };
+ }
+ return input.connected ? { status: 'connected' } : { status: 'disconnected' };
+}
+
+// Discriminated status for "can this scope's role edit reviewer settings",
+// so a still-loading or failed org-list fetch isn't silently treated as
+// "read-only" (it used to fall through to `role === undefined` -> false).
+export type PermissionState =
+ | { status: 'loading' }
+ | { status: 'error'; refetch: () => void; isRetrying: boolean }
+ | { status: 'ready'; canEdit: boolean };
+
+export function classifyPermission(input: {
+ isPersonal: boolean;
+ isLoading: boolean;
+ isError: boolean;
+ isFetching: boolean;
+ role: string | undefined;
+ refetch: () => unknown;
+}): PermissionState {
+ if (input.isPersonal) {
+ return { status: 'ready', canEdit: true };
+ }
+ if (input.isLoading) {
+ return { status: 'loading' };
+ }
+ if (input.isError) {
+ return { status: 'error', refetch: () => void input.refetch(), isRetrying: input.isFetching };
+ }
+ return { status: 'ready', canEdit: canManageOrganizationBilling(input.role) };
+}
diff --git a/apps/mobile/src/lib/external-link.ts b/apps/mobile/src/lib/external-link.ts
new file mode 100644
index 0000000000..2cfb5a4a3e
--- /dev/null
+++ b/apps/mobile/src/lib/external-link.ts
@@ -0,0 +1,10 @@
+import * as WebBrowser from 'expo-web-browser';
+import { toast } from 'sonner-native';
+
+export async function openExternalUrl(url: string, { label = 'link' }: { label?: string } = {}) {
+ try {
+ await WebBrowser.openBrowserAsync(url);
+ } catch {
+ toast.error(`Could not open ${label}`);
+ }
+}
diff --git a/apps/mobile/src/lib/form-sheet.ts b/apps/mobile/src/lib/form-sheet.ts
new file mode 100644
index 0000000000..7077801193
--- /dev/null
+++ b/apps/mobile/src/lib/form-sheet.ts
@@ -0,0 +1,15 @@
+import { Platform, StatusBar, useWindowDimensions } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+// Android formSheets can't hit 1.0 without clipping under the status bar, so
+// the "full" detent is capped just below the top inset there; iOS can use 1.
+export function useFormSheetDetents() {
+ const { height } = useWindowDimensions();
+ const { top } = useSafeAreaInsets();
+ const androidTopInset = top > 0 ? top : (StatusBar.currentHeight ?? 0);
+ const androidFullSheetDetent =
+ height > 0 ? Math.max(0.5, (height - androidTopInset) / height) : 1;
+ const fullSheetDetent = Platform.OS === 'android' ? androidFullSheetDetent : 1;
+
+ return { fullSheetDetent };
+}
diff --git a/apps/mobile/src/lib/hooks/instance-context-logic.test.ts b/apps/mobile/src/lib/hooks/instance-context-logic.test.ts
new file mode 100644
index 0000000000..b2a21e89d2
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/instance-context-logic.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { type ClawInstance, deriveInstanceContext } from './instance-context-logic';
+
+function makeInstance(overrides: Partial = {}): ClawInstance {
+ const instance: ClawInstance = {
+ id: 'row-1',
+ sandboxId: 'sandbox-1',
+ name: 'My Instance',
+ organizationId: null,
+ organizationName: null,
+ botName: null,
+ botEmoji: null,
+ status: 'running',
+ ...overrides,
+ };
+ return instance;
+}
+
+describe('deriveInstanceContext', () => {
+ it('returns loading while the list is still fetching', () => {
+ const refetch = vi.fn<() => void>();
+ expect(
+ deriveInstanceContext('sandbox-1', { data: undefined, isError: false }, refetch)
+ ).toEqual({
+ status: 'loading',
+ });
+ });
+
+ it('returns error when the initial load failed with no cached data', () => {
+ const refetch = vi.fn<() => void>();
+ const result = deriveInstanceContext('sandbox-1', { data: undefined, isError: true }, refetch);
+ expect(result.status).toBe('error');
+ if (result.status === 'error') {
+ result.refetch();
+ expect(refetch).toHaveBeenCalledOnce();
+ }
+ });
+
+ it('returns not_found when the list loaded successfully with no match', () => {
+ const refetch = vi.fn<() => void>();
+ const result = deriveInstanceContext(
+ 'sandbox-missing',
+ { data: [makeInstance()], isError: false },
+ refetch
+ );
+ expect(result).toEqual({ status: 'not_found' });
+ });
+
+ it('returns ready with organizationId null for a personal instance', () => {
+ const refetch = vi.fn<() => void>();
+ const instance = makeInstance({ organizationId: null });
+ const result = deriveInstanceContext(
+ 'sandbox-1',
+ { data: [instance], isError: false },
+ refetch
+ );
+ expect(result).toEqual({ status: 'ready', instance, organizationId: null, isOrg: false });
+ });
+
+ it('returns ready with isOrg true for an org instance', () => {
+ const refetch = vi.fn<() => void>();
+ const instance = makeInstance({ organizationId: 'org-1' });
+ const result = deriveInstanceContext(
+ 'sandbox-1',
+ { data: [instance], isError: false },
+ refetch
+ );
+ expect(result).toEqual({ status: 'ready', instance, organizationId: 'org-1', isOrg: true });
+ });
+
+ it('prefers cached data over a background refetch error (preserve stale data)', () => {
+ const refetch = vi.fn<() => void>();
+ const instance = makeInstance();
+ const result = deriveInstanceContext('sandbox-1', { data: [instance], isError: true }, refetch);
+ expect(result.status).toBe('ready');
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/instance-context-logic.ts b/apps/mobile/src/lib/hooks/instance-context-logic.ts
new file mode 100644
index 0000000000..52a53d2197
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/instance-context-logic.ts
@@ -0,0 +1,38 @@
+import { type inferRouterOutputs, type RootRouter } from '@kilocode/trpc';
+
+export type ClawInstance = inferRouterOutputs['kiloclaw']['listAllInstances'][number];
+
+export type InstanceContextResult =
+ | { status: 'loading' }
+ | { status: 'error'; refetch: () => void }
+ | { status: 'not_found' }
+ | { status: 'ready'; instance: ClawInstance; organizationId: string | null; isOrg: boolean };
+
+/**
+ * Derives instance context (org vs personal, or a terminal loading/error/
+ * not-found state) from the cached `listAllInstances` list. Pulled out as a
+ * pure function, with no react-query/react-native imports, so the status
+ * derivation can be unit tested without rendering a hook.
+ *
+ * Cached data always wins over a background refetch error — a stale-but-
+ * present match still resolves to `ready`/`not_found`; `error` only fires
+ * on an initial-load failure with no cached data yet.
+ */
+export function deriveInstanceContext(
+ sandboxId: string,
+ list: { data: ClawInstance[] | undefined; isError: boolean },
+ refetch: () => void
+): InstanceContextResult {
+ if (list.data !== undefined) {
+ const instance = list.data.find(i => i.sandboxId === sandboxId);
+ if (!instance) {
+ return { status: 'not_found' };
+ }
+ const organizationId = instance.organizationId ?? null;
+ return { status: 'ready', instance, organizationId, isOrg: Boolean(organizationId) };
+ }
+ if (list.isError) {
+ return { status: 'error', refetch };
+ }
+ return { status: 'loading' };
+}
diff --git a/apps/mobile/src/lib/hooks/save-chain.test.ts b/apps/mobile/src/lib/hooks/save-chain.test.ts
new file mode 100644
index 0000000000..7e37143c47
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/save-chain.test.ts
@@ -0,0 +1,182 @@
+import { describe, expect, it } from 'vitest';
+
+import { chainSave, inFlightSaveCount } from '@/lib/hooks/save-chain';
+
+// chainSave's internal chaining adds a few microtask hops per link (the
+// nested IIFE + awaitSettled). Flushing a generous, fixed number of
+// microtask turns is deterministic (no timers) and cheap enough to over-do.
+async function flushMicrotasks(turns = 10): Promise {
+ if (turns <= 0) {
+ return;
+ }
+ await Promise.resolve();
+ await flushMicrotasks(turns - 1);
+}
+
+describe('chainSave', () => {
+ it('runs the second save only after the first settles; last write wins', async () => {
+ const key = 'fifo-order';
+ const order: string[] = [];
+ const firstGate = Promise.withResolvers();
+
+ const p1 = chainSave(key, async () => {
+ order.push('first-start');
+ const value = await firstGate.promise;
+ order.push('first-end');
+ return value;
+ });
+
+ // eslint-disable-next-line typescript-eslint/require-await -- no await needed; return value is the whole point
+ const p2 = chainSave(key, async () => {
+ order.push('second-start');
+ return 'second-result';
+ });
+
+ // Flush pending microtasks — the second save must not have started yet,
+ // since it's waiting on the first to settle.
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(order).toEqual(['first-start']);
+
+ firstGate.resolve('first-result');
+ const [r1, r2] = await Promise.all([p1, p2]);
+
+ expect(order).toEqual(['first-start', 'first-end', 'second-start']);
+ expect(r1).toBe('first-result');
+ expect(r2).toBe('second-result');
+ });
+
+ it('keeps the chain running after a rejected save; the next save still runs', async () => {
+ const key = 'reject-survives';
+ const order: string[] = [];
+
+ // eslint-disable-next-line typescript-eslint/require-await -- no await needed; return value is the whole point
+ const p1 = chainSave(key, async () => {
+ order.push('first');
+ return 'ok';
+ });
+ // eslint-disable-next-line typescript-eslint/require-await -- no await needed; throw is the whole point
+ const p2 = chainSave(key, async () => {
+ order.push('second');
+ throw new Error('boom');
+ });
+ // eslint-disable-next-line typescript-eslint/require-await -- no await needed; return value is the whole point
+ const p3 = chainSave(key, async () => {
+ order.push('third');
+ return 'ok-again';
+ });
+
+ await expect(p1).resolves.toBe('ok');
+ await expect(p2).rejects.toThrow('boom');
+ await expect(p3).resolves.toBe('ok-again');
+ expect(order).toEqual(['first', 'second', 'third']);
+ });
+
+ it('surfaces the rejection to the caller without an unhandled rejection', async () => {
+ const key = 'no-unhandled-rejection';
+ const unhandled: unknown[] = [];
+ const onUnhandledRejection = (reason: unknown) => {
+ unhandled.push(reason);
+ };
+ process.on('unhandledRejection', onUnhandledRejection);
+
+ try {
+ // eslint-disable-next-line typescript-eslint/require-await -- no await needed; throw is the whole point
+ const pending = chainSave(key, async () => {
+ throw new Error('rejected');
+ });
+
+ await expect(pending).rejects.toThrow('rejected');
+ // Give the runtime a turn to flag an unhandled rejection if chainSave
+ // ever leaves the internal sequencing promise's rejection unhandled.
+ await new Promise(resolve => {
+ setImmediate(resolve);
+ });
+
+ expect(unhandled).toEqual([]);
+ } finally {
+ process.off('unhandledRejection', onUnhandledRejection);
+ }
+ });
+
+ it('serializes three queued saves for the same key (regression: tail must be registered before any await)', async () => {
+ const key = 'three-way-queueing';
+ const order: string[] = [];
+ const gateA = Promise.withResolvers();
+ const gateB = Promise.withResolvers();
+
+ const pA = chainSave(key, async () => {
+ order.push('A-start');
+ await gateA.promise;
+ order.push('A-end');
+ return 'a';
+ });
+ const pB = chainSave(key, async () => {
+ order.push('B-start');
+ await gateB.promise;
+ order.push('B-end');
+ return 'b';
+ });
+ // eslint-disable-next-line typescript-eslint/require-await -- no await needed; return value is the whole point
+ const pC = chainSave(key, async () => {
+ order.push('C-start');
+ return 'c';
+ });
+
+ // A is in flight; B and C are enqueued behind it. Neither should have
+ // started yet.
+ await flushMicrotasks();
+ expect(order).toEqual(['A-start']);
+
+ gateA.resolve(null);
+ // Let A finish and B start, but B is still gated on gateB (unresolved),
+ // so no amount of microtask flushing lets it finish — C must not start.
+ await flushMicrotasks();
+ expect(order).toEqual(['A-start', 'A-end', 'B-start']);
+
+ gateB.resolve(null);
+ await Promise.all([pA, pB, pC]);
+
+ expect(order).toEqual(['A-start', 'A-end', 'B-start', 'B-end', 'C-start']);
+ });
+
+ it('does not serialize saves for distinct keys', async () => {
+ const order: string[] = [];
+ const gate = Promise.withResolvers();
+
+ const p1 = chainSave('key-one', async () => {
+ order.push('one-start');
+ await gate.promise;
+ order.push('one-end');
+ return 'one';
+ });
+ // eslint-disable-next-line typescript-eslint/require-await -- no await needed; return value is the whole point
+ const p2 = chainSave('key-two', async () => {
+ order.push('two-start');
+ return 'two';
+ });
+
+ // key-two has no predecessor, so it must run immediately even though
+ // key-one is still gated.
+ await flushMicrotasks();
+ expect(order).toEqual(['one-start', 'two-start']);
+
+ gate.resolve(null);
+ await Promise.all([p1, p2]);
+ expect(order).toEqual(['one-start', 'two-start', 'one-end']);
+ });
+
+ it('releases the key from the in-flight map once its chain settles (regression: unbounded map leak)', async () => {
+ const key = 'cleanup-key';
+ const baseline = inFlightSaveCount();
+
+ // eslint-disable-next-line typescript-eslint/require-await -- no await needed; return value is the whole point
+ await chainSave(key, async () => 'done');
+
+ // The `.finally` cleanup runs as a continuation of the settled tail
+ // promise; give the microtask queue several turns to run it.
+ await flushMicrotasks();
+
+ expect(inFlightSaveCount()).toBe(baseline);
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/save-chain.ts b/apps/mobile/src/lib/hooks/save-chain.ts
new file mode 100644
index 0000000000..01ff440b0b
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/save-chain.ts
@@ -0,0 +1,48 @@
+// Pure promise-sequencing helper, kept dependency-free so it can be
+// vitest'd in node env without pulling in react-native/sonner transitively
+// (same reasoning as session-list-cache.ts).
+
+const inFlightSaves = new Map>();
+
+async function awaitSettled(promise: Promise): Promise {
+ try {
+ await promise;
+ } catch {
+ // Swallow — this is only used to sequence subsequent saves, not to
+ // propagate the outcome (the caller of chainSave gets the real result).
+ }
+}
+
+/**
+ * Runs `run` only after the previous call for the same `key` has settled
+ * (resolved or rejected), so concurrent saves for the same resource never
+ * race. FIFO, no dedupe/coalescing — the caller sees the real
+ * resolution/rejection of their own `run`, even when an earlier chained
+ * save failed. The tail is registered synchronously (before any await) so
+ * back-to-back callers each chain behind the correct predecessor, and each
+ * key is dropped from the map once its own tail settles.
+ */
+// eslint-disable-next-line typescript-eslint/require-await -- the awaits live inside the nested IIFEs so the tail is registered synchronously; see the doc comment above
+export async function chainSave(key: string, run: () => Promise): Promise {
+ const previous = inFlightSaves.get(key);
+ const next = (async () => {
+ if (previous) {
+ await awaitSettled(previous);
+ }
+ return run();
+ })();
+ // eslint-disable-next-line eslint-plugin-promise/prefer-await-to-then -- tail must reference its own settled promise for the cleanup guard below; wrapping this in an awaited helper reintroduces the pre-await `set` this fix removes
+ const tail: Promise = awaitSettled(next).finally(() => {
+ if (inFlightSaves.get(key) === tail) {
+ inFlightSaves.delete(key);
+ }
+ });
+ inFlightSaves.set(key, tail);
+ return next;
+}
+
+// Test-only: exposes the internal map size so tests can assert keys are
+// released once their chain settles, without reaching into module internals.
+export function inFlightSaveCount(): number {
+ return inFlightSaves.size;
+}
diff --git a/apps/mobile/src/lib/hooks/secure-store-preference.test.ts b/apps/mobile/src/lib/hooks/secure-store-preference.test.ts
new file mode 100644
index 0000000000..77bb0a3c6e
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/secure-store-preference.test.ts
@@ -0,0 +1,91 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { createSecureStorePreference } from './secure-store-preference';
+
+const { getItemAsync, setItemAsync, deleteItemAsync } = vi.hoisted(() => ({
+ getItemAsync: vi.fn(),
+ setItemAsync: vi.fn(),
+ deleteItemAsync: vi.fn(),
+}));
+vi.mock('expo-secure-store', () => ({ getItemAsync, setItemAsync, deleteItemAsync }));
+
+const { captureException } = vi.hoisted(() => ({ captureException: vi.fn() }));
+vi.mock('@sentry/react-native', () => ({ captureException }));
+
+const { toastError } = vi.hoisted(() => ({ toastError: vi.fn() }));
+vi.mock('sonner-native', () => ({ toast: { error: toastError } }));
+
+// eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+function flushMicrotasks(): Promise {
+ return new Promise(resolve => {
+ setImmediate(resolve);
+ });
+}
+
+// eslint-disable-next-line no-empty-function -- listener body is irrelevant, only subscribe()'s side effect (starting the load) is under test
+function noopListener(): void {}
+
+describe('createSecureStorePreference', () => {
+ beforeEach(() => {
+ getItemAsync.mockReset();
+ setItemAsync.mockReset();
+ deleteItemAsync.mockReset();
+ captureException.mockReset();
+ toastError.mockReset();
+ });
+
+ it('logs to Sentry (not a toast) on a read failure and keeps the default value', async () => {
+ getItemAsync.mockRejectedValue(new Error('disk error'));
+ const store = createSecureStorePreference({
+ key: 'k',
+ defaultValue: false,
+ parse: raw => raw === 'true',
+ serialize: value => (value ? 'true' : 'false'),
+ });
+
+ const unsubscribe = store.subscribe(noopListener);
+ await flushMicrotasks();
+
+ expect(store.get()).toBe(false);
+ expect(store.getHasLoaded()).toBe(true);
+ expect(captureException).toHaveBeenCalledWith(expect.any(Error));
+ expect(toastError).not.toHaveBeenCalled();
+ unsubscribe();
+ });
+
+ it('shows a toast on a write failure while keeping the in-memory value', async () => {
+ getItemAsync.mockResolvedValue(null);
+ setItemAsync.mockRejectedValue(new Error('disk full'));
+ const store = createSecureStorePreference({
+ key: 'k',
+ defaultValue: false,
+ parse: raw => raw === 'true',
+ serialize: value => (value ? 'true' : 'false'),
+ });
+
+ store.set(true);
+ expect(store.get()).toBe(true);
+
+ await flushMicrotasks();
+
+ expect(toastError).toHaveBeenCalledWith('Could not save setting');
+ expect(store.get()).toBe(true);
+ });
+
+ it('lets a set() before the initial load resolves win over the disk value', async () => {
+ getItemAsync.mockResolvedValue('true');
+ const store = createSecureStorePreference({
+ key: 'k',
+ defaultValue: false,
+ parse: raw => raw === 'true',
+ serialize: value => (value ? 'true' : 'false'),
+ });
+
+ const unsubscribe = store.subscribe(noopListener);
+ store.set(false);
+ await flushMicrotasks();
+
+ expect(store.get()).toBe(false);
+ unsubscribe();
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/secure-store-preference.ts b/apps/mobile/src/lib/hooks/secure-store-preference.ts
index 6034473689..269b9501c0 100644
--- a/apps/mobile/src/lib/hooks/secure-store-preference.ts
+++ b/apps/mobile/src/lib/hooks/secure-store-preference.ts
@@ -1,4 +1,6 @@
+import * as Sentry from '@sentry/react-native';
import * as SecureStore from 'expo-secure-store';
+import { toast } from 'sonner-native';
/**
* Module-level store for a SecureStore-backed preference so every hook
@@ -31,8 +33,11 @@ export function createSecureStorePreference(options: {
if (!dirty) {
value = parse(raw);
}
- } catch {
- // Keep the default on read failure.
+ } catch (error) {
+ // Keep the default on read failure — this runs on mount, before the
+ // user has done anything, so there's nothing actionable to tell them.
+ // Just log so we can see failure rates.
+ Sentry.captureException(error);
} finally {
hasLoaded = true;
emit();
@@ -43,7 +48,10 @@ export function createSecureStorePreference(options: {
try {
await SecureStore.setItemAsync(key, serialize(next));
} catch {
- // Keep the in-memory preference even if the storage write fails.
+ // Keep the in-memory preference so the session still works, but the
+ // change won't survive relaunch — tell the user so it's not a silent
+ // surprise later.
+ toast.error('Could not save setting');
}
};
diff --git a/apps/mobile/src/lib/hooks/use-agent-sessions.ts b/apps/mobile/src/lib/hooks/use-agent-sessions.ts
index 3413c9c07b..1dbd4de781 100644
--- a/apps/mobile/src/lib/hooks/use-agent-sessions.ts
+++ b/apps/mobile/src/lib/hooks/use-agent-sessions.ts
@@ -205,7 +205,12 @@ export function useAgentSessionSearch(options: UseAgentSessionSearchOptions) {
const sessions = useMemo(() => query.data?.results ?? [], [query.data]);
const dateGroups = useMemo(() => groupSessionsByDate(sessions), [sessions]);
- return { dateGroups, isPending: query.isPending, isError: query.isError };
+ return {
+ dateGroups,
+ isPending: query.isPending,
+ isError: query.isError,
+ refetch: query.refetch,
+ };
}
// ── Main hook ────────────────────────────────────────────────────────
@@ -243,6 +248,15 @@ export function useAgentSessions(options?: UseAgentSessionsOptions) {
dateGroups,
isLoading: stored.isLoading || active.isLoading,
isError: stored.isError || active.isError,
+ // Stored and active sessions come from independent queries with very
+ // different failure modes: a transient active-poll blip (10s interval)
+ // is common and should never hide stored history, while a stored-list
+ // failure is the one that actually blocks showing sessions at all.
+ // Callers that need to tell these apart (e.g. deciding promo vs error
+ // vs "keep showing stale data") should use these instead of `isError`.
+ storedIsError: stored.isError,
+ storedIsSuccess: stored.isSuccess,
+ activeIsError: active.isError,
hasNextPage: stored.hasNextPage,
isFetchingNextPage: stored.isFetchingNextPage,
fetchNextPage: stored.fetchNextPage,
diff --git a/apps/mobile/src/lib/hooks/use-available-models.ts b/apps/mobile/src/lib/hooks/use-available-models.ts
index b407e6d2b0..626d4114ea 100644
--- a/apps/mobile/src/lib/hooks/use-available-models.ts
+++ b/apps/mobile/src/lib/hooks/use-available-models.ts
@@ -120,7 +120,7 @@ export function useOrgDefaultModel(organizationId: string | undefined) {
}
export function useAvailableModels(organizationId: string | undefined) {
- const { data, isLoading } = useQuery({
+ const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ['available-models', organizationId] as const,
queryFn: fetchModels.bind(null, organizationId),
staleTime: 60_000,
@@ -168,5 +168,5 @@ export function useAvailableModels(organizationId: string | undefined) {
}));
}, [data]);
- return { models, isLoading };
+ return { models, isLoading, isError, error, refetch };
}
diff --git a/apps/mobile/src/lib/hooks/use-code-reviewer.ts b/apps/mobile/src/lib/hooks/use-code-reviewer.ts
index daa9cb94de..0da1b14a80 100644
--- a/apps/mobile/src/lib/hooks/use-code-reviewer.ts
+++ b/apps/mobile/src/lib/hooks/use-code-reviewer.ts
@@ -4,17 +4,26 @@ import { toast } from 'sonner-native';
import {
buildSaveConfigInput,
type ConfigPatch,
+ PERSONAL_SCOPE,
type ReviewConfigData,
type ReviewerPlatform,
} from '@/lib/code-reviewer-config';
+import { chainSave } from '@/lib/hooks/save-chain';
import { trpcClient, useTRPC } from '@/lib/trpc';
+import { pick } from '@/lib/utils';
-export const PERSONAL_SCOPE = 'personal';
+export { PERSONAL_SCOPE };
function isPersonal(scope: string) {
return scope === PERSONAL_SCOPE;
}
+// chainSave keys in-flight saves by "scope:platform", so config saves for
+// the same reviewer config are never in flight concurrently — each waits
+// for the previous one on the same key to settle before running (see
+// save-chain.ts). It's module-level there (not per-hook-instance) so it
+// holds across remounts of the same screen.
+
// The personal router only serves github/gitlab (bitbucket is org-only by UI
// construction). This narrows a ReviewerPlatform down to what the personal
// procedures accept, without an `as` cast — the 'bitbucket' branch is dead
@@ -130,34 +139,31 @@ export function useReviewConfigCacheReader(scope: string, platform: ReviewerPlat
return () => queryClient.getQueryData(queryKey);
}
-function pick(
- config: ReviewConfigData,
- keys: readonly K[]
-): Pick {
- const result: Partial = {};
- for (const key of keys) {
- result[key] = config[key];
- }
- return result as Pick;
-}
-
export function useToggleReviewer(scope: string, platform: ReviewerPlatform) {
const queryClient = useQueryClient();
const queryKey = useReviewConfigQueryKey(scope, platform);
return useMutation({
- // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
- mutationFn: (vars: { isEnabled: boolean }) =>
- isPersonal(scope)
- ? trpcClient.personalReviewAgent.toggleReviewAgent.mutate({
+ mutationFn: async (vars: { isEnabled: boolean }) => {
+ const result = isPersonal(scope)
+ ? await trpcClient.personalReviewAgent.toggleReviewAgent.mutate({
platform: toPersonalPlatform(platform),
isEnabled: vars.isEnabled,
})
- : trpcClient.organizations.reviewAgent.toggleReviewAgent.mutate({
+ : await trpcClient.organizations.reviewAgent.toggleReviewAgent.mutate({
organizationId: scope,
platform,
isEnabled: vars.isEnabled,
- }),
+ });
+ // The output type widens `success` to `boolean` (not a `true`
+ // literal), so a domain failure here must not be treated as a
+ // resolved mutation — throwing routes it to onError (toast) instead
+ // of letting callers' onSuccess fire haptics/navigation as if it worked.
+ if (!result.success) {
+ throw new Error('Failed to update reviewer');
+ }
+ return result;
+ },
onMutate: async vars => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData(queryKey);
@@ -177,37 +183,85 @@ export function useToggleReviewer(scope: string, platform: ReviewerPlatform) {
});
}
+function gitLabWebhookWarningQueryKey(scope: string, platform: ReviewerPlatform) {
+ return ['codeReviewerGitLabWebhookWarning', scope, platform] as const;
+}
+
+/**
+ * Durable warning surfaced when a GitLab config save partially fails to
+ * sync repository webhooks (`saveReviewConfig` still resolves `success:
+ * true` in that case — the sync errors are nested in `webhookSync.errors`
+ * and easy to miss). Stored in the query cache rather than component state
+ * so it survives navigation. `useSaveReviewConfig`'s mutationFn recomputes
+ * this flag from the fresh `webhookSync.errors` on every successful save
+ * (including a "Retry" that just resubmits the current config), so it
+ * clears itself once the sync actually succeeds — there is no separate
+ * dismiss action.
+ */
+export function useGitLabWebhookWarning(scope: string, platform: ReviewerPlatform) {
+ const queryKey = gitLabWebhookWarningQueryKey(scope, platform);
+ const { data } = useQuery({
+ queryKey,
+ queryFn: () => false,
+ initialData: false,
+ staleTime: Infinity,
+ });
+
+ return { hasWebhookSyncWarning: data };
+}
+
export function useSaveReviewConfig(scope: string, platform: ReviewerPlatform) {
const queryClient = useQueryClient();
const queryKey = useReviewConfigQueryKey(scope, platform);
+ const webhookWarningQueryKey = gitLabWebhookWarningQueryKey(scope, platform);
+ const saveChainKey = `${scope}:${platform}`;
return useMutation({
// eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
- mutationFn: (patch: ConfigPatch) => {
- const config = queryClient.getQueryData(queryKey);
- if (!config) {
- throw new Error('Config not loaded yet');
- }
- const input = buildSaveConfigInput(platform, config, patch);
- if (isPersonal(scope)) {
- // The personal schema only accepts numeric repository IDs
- // (bitbucket, the only string-ID platform, is org-only). Filtering
- // keeps this a type-safe narrowing rather than a cast; the branch
- // is only ever reached with platform !== 'bitbucket' in practice.
- const numericSelectedRepositoryIds = input.selectedRepositoryIds.filter(
- (id): id is number => typeof id === 'number'
- );
- return trpcClient.personalReviewAgent.saveReviewConfig.mutate({
- ...input,
- platform: toPersonalPlatform(platform),
- selectedRepositoryIds: numericSelectedRepositoryIds,
- });
- }
- return trpcClient.organizations.reviewAgent.saveReviewConfig.mutate({
- ...input,
- organizationId: scope,
- });
- },
+ mutationFn: (patch: ConfigPatch) =>
+ // Rapid taps (e.g. toggling several focus areas in a row) each send a
+ // full-config snapshot; without serializing them, two in-flight saves
+ // for the same scope+platform can resolve out of order and the
+ // earlier response can stomp the later one's result. Chaining onto
+ // the prior in-flight save for this key keeps them in order — simple
+ // FIFO, no dedupe/coalescing.
+ chainSave(saveChainKey, async () => {
+ const config = queryClient.getQueryData(queryKey);
+ if (!config) {
+ throw new Error('Config not loaded yet');
+ }
+ const input = buildSaveConfigInput(platform, config, patch);
+ // The personal schema only accepts numeric repository IDs (bitbucket,
+ // the only string-ID platform, is org-only). Filtering keeps this a
+ // type-safe narrowing rather than a cast; the personal branch is only
+ // ever reached with platform !== 'bitbucket' in practice.
+ const result = isPersonal(scope)
+ ? await trpcClient.personalReviewAgent.saveReviewConfig.mutate({
+ ...input,
+ platform: toPersonalPlatform(platform),
+ selectedRepositoryIds: input.selectedRepositoryIds.filter(
+ (id): id is number => typeof id === 'number'
+ ),
+ })
+ : await trpcClient.organizations.reviewAgent.saveReviewConfig.mutate({
+ ...input,
+ organizationId: scope,
+ });
+ // Same reasoning as useToggleReviewer: `success` is typed as `boolean`,
+ // not a `true` literal, so a domain failure must throw rather than
+ // resolve — otherwise onSuccess callers close sheets/navigate away as
+ // if the save worked.
+ if (!result.success) {
+ throw new Error('Failed to save review config');
+ }
+ if (platform === 'gitlab') {
+ queryClient.setQueryData(
+ webhookWarningQueryKey,
+ (result.webhookSync?.errors.length ?? 0) > 0
+ );
+ }
+ return result;
+ }),
onMutate: async patch => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData(queryKey);
@@ -231,18 +285,12 @@ export function useSaveReviewConfig(scope: string, platform: ReviewerPlatform) {
});
}
-export function useCanEditReviewer(scope: string) {
- const trpc = useTRPC();
- const { data: orgs } = useQuery({
- ...trpc.organizations.list.queryOptions(),
- enabled: !isPersonal(scope),
- });
- if (isPersonal(scope)) {
- return true;
- }
- const role = orgs?.find(org => org.organizationId === scope)?.role;
- return role === 'owner' || role === 'billing_manager';
-}
+// Discriminated provider-connection and permission state moved to
+// use-reviewer-permission.ts (kept this file under the max-lines limit);
+// re-exported here so existing call sites keep importing from
+// use-code-reviewer without churn.
+export { classifyProviderState } from '@/lib/code-reviewer-status';
+export { useReviewerEditGuard, useReviewerPermission } from '@/lib/hooks/use-reviewer-permission';
// Bitbucket is org-only, so unlike the GitHub/GitLab status hooks above
// there is no personal-vs-org split here.
diff --git a/apps/mobile/src/lib/hooks/use-instance-context.ts b/apps/mobile/src/lib/hooks/use-instance-context.ts
index 8085284399..e9ad5aca9f 100644
--- a/apps/mobile/src/lib/hooks/use-instance-context.ts
+++ b/apps/mobile/src/lib/hooks/use-instance-context.ts
@@ -1,10 +1,14 @@
-import { type inferRouterOutputs, type RootRouter } from '@kilocode/trpc';
import { useQuery } from '@tanstack/react-query';
-import { useMemo } from 'react';
+import { useCallback, useMemo } from 'react';
+import {
+ type ClawInstance,
+ deriveInstanceContext,
+ type InstanceContextResult,
+} from './instance-context-logic';
import { useTRPC } from '@/lib/trpc';
-export type ClawInstance = inferRouterOutputs['kiloclaw']['listAllInstances'][number];
+export type { ClawInstance, InstanceContextResult };
type ListPollDecider = (instances: ClawInstance[] | undefined) => number;
@@ -22,30 +26,28 @@ export function useAllKiloClawInstances(refetchInterval: number | ListPollDecide
);
}
-/**
- * Resolves instance context (org vs personal) by looking up the cached
- * `listAllInstances` data.
- *
- * - `isResolved: false` — data not yet loaded / instance not found.
- * All downstream queries and mutations should stay disabled.
- * - `isResolved: true, organizationId: null` — personal instance.
- * - `isResolved: true, organizationId: string` — org instance.
- */
-export function useInstanceContext(sandboxId: string) {
+/** The instance's org id once `useInstanceContext` resolves to `ready`, otherwise `undefined`. */
+export function instanceOrgId(context: InstanceContextResult): string | null | undefined {
+ return context.status === 'ready' ? context.organizationId : undefined;
+}
+
+export function useInstanceContext(sandboxId: string): InstanceContextResult {
const trpc = useTRPC();
- const { data: match } = useQuery({
- ...trpc.kiloclaw.listAllInstances.queryOptions(undefined, {
+ const query = useQuery(
+ trpc.kiloclaw.listAllInstances.queryOptions(undefined, {
staleTime: 30_000,
refetchInterval: 30_000,
- }),
- select: instances => instances.find(i => i.sandboxId === sandboxId),
- });
+ })
+ );
+ const queryRefetch = query.refetch;
+ const refetch = useCallback(() => {
+ void queryRefetch();
+ }, [queryRefetch]);
- return useMemo(() => {
- if (match === undefined) {
- return { organizationId: undefined, isResolved: false, isOrg: false } as const;
- }
- const organizationId = match.organizationId ?? null;
- return { organizationId, isResolved: true, isOrg: Boolean(organizationId) } as const;
- }, [match]);
+ const data = query.data;
+ const isError = query.isError;
+ return useMemo(
+ () => deriveInstanceContext(sandboxId, { data, isError }, refetch),
+ [sandboxId, data, isError, refetch]
+ );
}
diff --git a/apps/mobile/src/lib/hooks/use-kiloclaw-billing.ts b/apps/mobile/src/lib/hooks/use-kiloclaw-billing.ts
index d9d98f8aff..e1f483fcbe 100644
--- a/apps/mobile/src/lib/hooks/use-kiloclaw-billing.ts
+++ b/apps/mobile/src/lib/hooks/use-kiloclaw-billing.ts
@@ -1,4 +1,4 @@
-import { parseTimestamp } from '@/lib/utils';
+import { formatDate, parseTimestamp } from '@/lib/utils';
import { type useKiloClawBillingStatus } from './use-kiloclaw-queries';
@@ -80,11 +80,7 @@ export function deriveBannerState(billing: ClawBillingStatus): ClawBannerState {
}
export function formatBillingDate(iso: string): string {
- return parseTimestamp(iso).toLocaleDateString(undefined, {
- month: 'long',
- day: 'numeric',
- year: 'numeric',
- });
+ return formatDate(parseTimestamp(iso));
}
export function formatRemainingDays(daysRemaining: number): string {
diff --git a/apps/mobile/src/lib/hooks/use-kiloclaw-mutations.ts b/apps/mobile/src/lib/hooks/use-kiloclaw-mutations.ts
index 61f4226d91..9c49258d5b 100644
--- a/apps/mobile/src/lib/hooks/use-kiloclaw-mutations.ts
+++ b/apps/mobile/src/lib/hooks/use-kiloclaw-mutations.ts
@@ -118,7 +118,9 @@ export function useKiloClawMutations(organizationId?: string | null) {
function optimistic>(
key: unknown[],
updater: (old: TData, input: TInput) => TData,
- settle?: () => Promise
+ // silent: caller shows the error inline (e.g. a modal that stays open on
+ // failure per Pattern P2) instead of the default centralized toast.
+ options?: { settle?: () => Promise; silent?: boolean }
) {
return {
onMutate: async (input: TInput) => {
@@ -131,10 +133,12 @@ export function useKiloClawMutations(organizationId?: string | null) {
if (context?.previous) {
queryClient.setQueryData(key, context.previous);
}
- onMutationError(error);
+ if (!options?.silent) {
+ onMutationError(error);
+ }
},
onSettled:
- settle ??
+ options?.settle ??
(async () => {
await queryClient.invalidateQueries({ queryKey: key });
}),
@@ -263,7 +267,7 @@ export function useKiloClawMutations(organizationId?: string | null) {
...(input.security != null && { execSecurity: input.security }),
...(input.ask != null && { execAsk: input.ask }),
}),
- invalidateStatus
+ { settle: invalidateStatus }
),
retry: retryTransient,
retryDelay: retryTransientDelay,
@@ -318,7 +322,11 @@ export function useKiloClawMutations(organizationId?: string | null) {
},
onSettled: invalidateStatusAndPin,
}),
- approvePairingRequest: useMutation({
+ approvePairingRequest: useMutation<
+ unknown,
+ { message: string },
+ { channel: string; code: string }
+ >({
...dispatch(
trpc.kiloclaw.approvePairingRequest,
trpc.organizations.kiloclaw.approvePairingRequest
@@ -328,7 +336,7 @@ export function useKiloClawMutations(organizationId?: string | null) {
},
onError: onMutationError,
}),
- approveDevicePairingRequest: useMutation({
+ approveDevicePairingRequest: useMutation({
...dispatch(
trpc.kiloclaw.approveDevicePairingRequest,
trpc.organizations.kiloclaw.approveDevicePairingRequest
@@ -354,15 +362,16 @@ export function useKiloClawMutations(organizationId?: string | null) {
...old,
gmailNotificationsEnabled: input.enabled,
}),
- invalidateStatus
+ { settle: invalidateStatus }
),
}),
renameInstance: useMutation({
...dispatch(trpc.kiloclaw.renameInstance, trpc.organizations.kiloclaw.renameInstance),
+ // silent: RenameModal (the only caller) shows the error inline (P2).
...optimistic(
statusKey,
(old, input: { name: string | null }) => ({ ...old, name: input.name }),
- invalidateStatus
+ { settle: invalidateStatus, silent: true }
),
}),
destroy: useMutation({
@@ -404,7 +413,10 @@ export function useKiloClawMutations(organizationId?: string | null) {
trpc.kiloclaw.updateKiloCodeConfig,
trpc.organizations.kiloclaw.updateKiloCodeConfig
),
- ...optimistic(configKey, (old, input: Record) => ({ ...old, ...input })),
+ ...optimistic(configKey, (old, input: { kilocodeDefaultModel: string }) => ({
+ ...old,
+ ...input,
+ })),
}),
// Errors are categorized at the onboarding screen (locked/billing conflict,
// quarantined, generic). The screen's callsite `onError` owns user-visible
diff --git a/apps/mobile/src/lib/hooks/use-kiloclaw-queries.ts b/apps/mobile/src/lib/hooks/use-kiloclaw-queries.ts
index 53a99e2b66..4eb64560ef 100644
--- a/apps/mobile/src/lib/hooks/use-kiloclaw-queries.ts
+++ b/apps/mobile/src/lib/hooks/use-kiloclaw-queries.ts
@@ -1,4 +1,5 @@
-import { useQuery } from '@tanstack/react-query';
+import { type inferRouterOutputs, type RootRouter } from '@kilocode/trpc';
+import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { deriveMobileOnboardingStateFromBilling } from '@/lib/derive-mobile-onboarding-state';
@@ -47,23 +48,42 @@ export function useKiloClawStatusQueryKey(organizationId: string | null) {
: trpc.kiloclaw.getStatus.queryKey();
}
-export function useKiloClawBillingStatus(enabled = true) {
+type BillingData = inferRouterOutputs['kiloclaw']['getBillingStatus'];
+type BillingPollDecider = (data: BillingData | undefined) => number;
+
+export function useKiloClawBillingStatus(
+ enabled = true,
+ refetchInterval: BillingPollDecider = () => 60_000
+) {
const trpc = useTRPC();
return useQuery(
trpc.kiloclaw.getBillingStatus.queryOptions(undefined, {
enabled,
- refetchInterval: enabled ? 60_000 : false,
+ refetchInterval: enabled
+ ? (query: { state: { data?: BillingData } }) => refetchInterval(query.state.data)
+ : false,
})
);
}
+// While the subscription is still settling server-side, poll much faster so
+// the "finishing setup" wait is as short as possible instead of sitting on
+// static copy for up to a minute.
+const PENDING_SETTLEMENT_POLL_MS = 5000;
+const DEFAULT_BILLING_POLL_MS = 60_000;
+
/**
* Mobile KiloClaw onboarding state, derived client-side from
* `getBillingStatus`. See `lib/derive-mobile-onboarding-state.ts` for scope
* and limitations.
*/
export function useKiloClawMobileOnboardingState(enabled = true) {
- const billing = useKiloClawBillingStatus(enabled);
+ const billing = useKiloClawBillingStatus(enabled, data => {
+ if (data && deriveMobileOnboardingStateFromBilling(data).state === 'pending_settlement') {
+ return PENDING_SETTLEMENT_POLL_MS;
+ }
+ return DEFAULT_BILLING_POLL_MS;
+ });
const data = useMemo(() => {
if (!billing.data) {
return undefined;
@@ -184,16 +204,19 @@ export function useKiloClawAvailableVersions(
) {
const trpc = useTRPC();
const { isOrg, personalEnabled, orgEnabled, orgInput } = resolveContext(organizationId);
+ // keepPreviousData: `limit` grows as the caller loads more pages — without
+ // this, bumping it would blank the already-rendered list while the bigger
+ // page refetches.
const personal = useQuery(
trpc.kiloclaw.listAvailableVersions.queryOptions(
{ offset, limit },
- { enabled: personalEnabled, staleTime: 5 * 60_000 }
+ { enabled: personalEnabled, staleTime: 5 * 60_000, placeholderData: keepPreviousData }
)
);
const org = useQuery(
trpc.organizations.kiloclaw.listAvailableVersions.queryOptions(
{ ...orgInput, offset, limit },
- { enabled: orgEnabled, staleTime: 5 * 60_000 }
+ { enabled: orgEnabled, staleTime: 5 * 60_000, placeholderData: keepPreviousData }
)
);
return isOrg ? org : personal;
diff --git a/apps/mobile/src/lib/hooks/use-manual-refresh.ts b/apps/mobile/src/lib/hooks/use-manual-refresh.ts
new file mode 100644
index 0000000000..547b2b94e0
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-manual-refresh.ts
@@ -0,0 +1,28 @@
+import { useCallback, useState } from 'react';
+import { toast } from 'sonner-native';
+
+// Wraps a pull-to-refresh refetch with a "refreshing" flag and a toast on
+// failure. Callers with multiple queries to refetch should reduce their
+// results into a single `{ isError }` before passing `refetch` in.
+export function useManualRefresh(
+ refetch: () => Promise<{ isError: boolean }>,
+ errorMessage: string
+): [boolean, () => void] {
+ const [refreshing, setRefreshing] = useState(false);
+
+ const onRefresh = useCallback(() => {
+ void (async () => {
+ setRefreshing(true);
+ try {
+ const result = await refetch();
+ if (result.isError) {
+ toast.error(errorMessage);
+ }
+ } finally {
+ setRefreshing(false);
+ }
+ })();
+ }, [refetch, errorMessage]);
+
+ return [refreshing, onRefresh];
+}
diff --git a/apps/mobile/src/lib/hooks/use-model-preferences.ts b/apps/mobile/src/lib/hooks/use-model-preferences.ts
index 322b8d3626..ef1793f6f6 100644
--- a/apps/mobile/src/lib/hooks/use-model-preferences.ts
+++ b/apps/mobile/src/lib/hooks/use-model-preferences.ts
@@ -1,9 +1,10 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { type inferRouterOutputs, type RootRouter } from '@kilocode/trpc';
-import { useCallback, useMemo } from 'react';
+import { useCallback, useMemo, useState } from 'react';
import { toast } from 'sonner-native';
-import { useTRPC } from '@/lib/trpc';
+import { chainSave } from '@/lib/hooks/save-chain';
+import { trpcClient, useTRPC } from '@/lib/trpc';
type ModelPreferences = inferRouterOutputs['modelPreferences']['get'];
@@ -11,9 +12,16 @@ const onError = (error: { message: string }) => {
toast.error(error.message || 'Something went wrong');
};
+// Favorites are stored per user (not per organization), so one chain key
+// covers every picker instance.
+const FAVORITES_CHAIN_KEY = 'model-preferences-favorites';
+
export function useModelPreferences(organizationId: string | undefined) {
const trpc = useTRPC();
const queryClient = useQueryClient();
+ // Favorite-toggle failures are surfaced inline in the model picker sheet
+ // (the toast layer sits behind it), not via the shared toast `onError`.
+ const [favoritesError, setFavoritesError] = useState(null);
const input = useMemo(() => (organizationId ? { organizationId } : undefined), [organizationId]);
@@ -47,7 +55,7 @@ export function useModelPreferences(organizationId: string | undefined) {
if (context?.previous) {
queryClient.setQueryData(trpc.modelPreferences.get.queryKey(input), context.previous);
}
- onError(error);
+ setFavoritesError(error.message || 'Could not update favorites');
},
[queryClient, trpc.modelPreferences.get, input]
);
@@ -66,35 +74,42 @@ export function useModelPreferences(organizationId: string | undefined) {
})
);
- const addFavorite = useMutation(
- trpc.modelPreferences.addFavorite.mutationOptions({
- onMutate: async ({ model }) => {
- const context = await applyOptimisticFavorites(favorites =>
- favorites.includes(model) ? favorites : [...favorites, model]
- );
- return context;
- },
- onError: (error, _input, context) => {
- rollbackFavorites(error, context);
- },
- onSettled: invalidate,
- })
- );
-
- const removeFavorite = useMutation(
- trpc.modelPreferences.removeFavorite.mutationOptions({
- onMutate: async ({ model }) => {
- const context = await applyOptimisticFavorites(favorites =>
- favorites.filter(id => id !== model)
- );
- return context;
- },
- onError: (error, _input, context) => {
- rollbackFavorites(error, context);
- },
- onSettled: invalidate,
- })
- );
+ // Rapid favorite taps (add/remove in quick succession) each send a full
+ // request; without serializing them, two in-flight requests can resolve
+ // out of order and the earlier response can stomp the later one's result.
+ // Chaining onto the prior in-flight request keeps them in order — simple
+ // FIFO, no dedupe/coalescing (see save-chain.ts).
+ const addFavorite = useMutation({
+ mutationFn: (vars: { model: string }) =>
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ chainSave(FAVORITES_CHAIN_KEY, () => trpcClient.modelPreferences.addFavorite.mutate(vars)),
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ onMutate: ({ model }) => {
+ setFavoritesError(null);
+ return applyOptimisticFavorites(favorites =>
+ favorites.includes(model) ? favorites : [...favorites, model]
+ );
+ },
+ onError: (error, _input, context) => {
+ rollbackFavorites(error, context);
+ },
+ onSettled: invalidate,
+ });
+
+ const removeFavorite = useMutation({
+ mutationFn: (vars: { model: string }) =>
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ chainSave(FAVORITES_CHAIN_KEY, () => trpcClient.modelPreferences.removeFavorite.mutate(vars)),
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ onMutate: ({ model }) => {
+ setFavoritesError(null);
+ return applyOptimisticFavorites(favorites => favorites.filter(id => id !== model));
+ },
+ onError: (error, _input, context) => {
+ rollbackFavorites(error, context);
+ },
+ onSettled: invalidate,
+ });
const setFavorites = useMutation(
trpc.modelPreferences.setFavorites.mutationOptions({
@@ -117,6 +132,7 @@ export function useModelPreferences(organizationId: string | undefined) {
return {
favorites: query.data?.favorites ?? [],
+ favoritesError,
lastSelected: query.data?.lastSelected ?? null,
isLoading: query.isLoading,
setLastSelected: setLastSelected.mutate,
diff --git a/apps/mobile/src/lib/hooks/use-organization-mutations.ts b/apps/mobile/src/lib/hooks/use-organization-mutations.ts
index 3d3f14871f..2bf5f7ef46 100644
--- a/apps/mobile/src/lib/hooks/use-organization-mutations.ts
+++ b/apps/mobile/src/lib/hooks/use-organization-mutations.ts
@@ -12,7 +12,21 @@ const onMutationError = (error: { message: string }) => {
toast.error(error.message || 'Something went wrong');
};
-export function useOrganizationMutations(organizationId: string) {
+type UseOrganizationMutationsOptions = {
+ /**
+ * member-limit-sheet renders `updateMember` errors inline (Pattern P2) and
+ * owns that mutation's error feedback for its caller. member-row.tsx's
+ * action-sheet role change has no persistent surface to show an inline
+ * error in, so it keeps the default toast — hence this is per-hook-call
+ * rather than a blanket change to the mutation itself.
+ */
+ silenceUpdateMemberToast?: boolean;
+};
+
+export function useOrganizationMutations(
+ organizationId: string,
+ { silenceUpdateMemberToast }: UseOrganizationMutationsOptions = {}
+) {
const trpc = useTRPC();
const queryClient = useQueryClient();
@@ -33,7 +47,10 @@ export function useOrganizationMutations(organizationId: string) {
// Every optimistic mutation here only touches the withMembers cache, so the
// key is fixed rather than threaded through like use-kiloclaw-mutations.ts
// (which juggles many caches across a personal/org split).
- function optimistic(updater: (old: OrgWithMembers, input: TInput) => OrgWithMembers) {
+ function optimistic(
+ updater: (old: OrgWithMembers, input: TInput) => OrgWithMembers,
+ { silent }: { silent?: boolean } = {}
+ ) {
return {
onMutate: async (input: TInput) => {
await queryClient.cancelQueries({ queryKey: withMembersKey });
@@ -51,7 +68,9 @@ export function useOrganizationMutations(organizationId: string) {
if (context?.previous) {
queryClient.setQueryData(withMembersKey, context.previous);
}
- onMutationError(error);
+ if (!silent) {
+ onMutationError(error);
+ }
},
onSettled: invalidateAll,
};
@@ -83,8 +102,10 @@ export function useOrganizationMutations(organizationId: string) {
);
return { previousWithMembers, previousList };
},
+ // No onMutationError toast here: RenameModal (the only caller) shows
+ // the error inline while it stays open (see Pattern P2).
onError: (
- error: { message: string },
+ _error: { message: string },
_input,
context?: { previousWithMembers?: OrgWithMembers; previousList?: OrgListEntry[] }
) => {
@@ -94,17 +115,17 @@ export function useOrganizationMutations(organizationId: string) {
if (context?.previousList) {
queryClient.setQueryData(listKey, context.previousList);
}
- onMutationError(error);
},
onSettled: invalidateAll,
}),
+ // No onMutationError toast here: invite-member-sheet (the only caller)
+ // shows the error inline while it stays open (see Pattern P2).
invite: useMutation({
// eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
mutationFn: (input: { email: string; role: OrgRole }) =>
trpcClient.organizations.members.invite.mutate({ organizationId, ...input }),
onSuccess: invalidateWithMembers,
- onError: onMutationError,
onSettled: invalidateAll,
}),
@@ -129,7 +150,8 @@ export function useOrganizationMutations(organizationId: string) {
}
: member
),
- })
+ }),
+ { silent: silenceUpdateMemberToast }
),
}),
@@ -157,6 +179,8 @@ export function useOrganizationMutations(organizationId: string) {
})),
}),
+ // No onMutationError toast here: low-balance-alert-sheet (the only
+ // caller) shows the error inline while it stays open (see Pattern P2).
updateMinimumBalanceAlert: useMutation({
// eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
mutationFn: (input: {
@@ -172,20 +196,27 @@ export function useOrganizationMutations(organizationId: string) {
enabled: boolean;
minimum_balance?: number;
minimum_balance_alert_email?: string[];
- }>((old, input) => {
- if (input.enabled) {
- return {
- ...old,
- settings: {
- ...old.settings,
- minimum_balance: input.minimum_balance,
- minimum_balance_alert_email: input.minimum_balance_alert_email,
- },
- };
- }
- const { minimum_balance: _mb, minimum_balance_alert_email: _mbae, ...rest } = old.settings;
- return { ...old, settings: rest };
- }),
+ }>(
+ (old, input) => {
+ if (input.enabled) {
+ return {
+ ...old,
+ settings: {
+ ...old.settings,
+ minimum_balance: input.minimum_balance,
+ minimum_balance_alert_email: input.minimum_balance_alert_email,
+ },
+ };
+ }
+ const {
+ minimum_balance: _mb,
+ minimum_balance_alert_email: _mbae,
+ ...rest
+ } = old.settings;
+ return { ...old, settings: rest };
+ },
+ { silent: true }
+ ),
}),
};
}
diff --git a/apps/mobile/src/lib/hooks/use-organization-queries.ts b/apps/mobile/src/lib/hooks/use-organization-queries.ts
index 5f255e7787..87cf1fb655 100644
--- a/apps/mobile/src/lib/hooks/use-organization-queries.ts
+++ b/apps/mobile/src/lib/hooks/use-organization-queries.ts
@@ -14,12 +14,18 @@ export function useOrgRole() {
const trpc = useTRPC();
const { token } = useAuth();
const { organizationId } = useOrganization();
- const { data: orgs, isLoading } = useQuery({
+ const {
+ data: orgs,
+ isLoading,
+ isError,
+ isFetching,
+ refetch,
+ } = useQuery({
...trpc.organizations.list.queryOptions(),
enabled: token != null,
});
const org = orgs?.find(entry => entry.organizationId === organizationId);
- return { organizationId, role: org?.role, org, isLoading };
+ return { organizationId, role: org?.role, org, isLoading, isError, isFetching, refetch };
}
export type OrgListEntry = NonNullable['org']>;
@@ -27,6 +33,24 @@ export type OrgRole = OrgListEntry['role'];
export const isMoneyRole = canManageOrganizationBilling;
+/**
+ * Reconciles the persisted org selection (SecureStore, read via
+ * `useOrganization()`) against the loaded org list. `organizationId` alone
+ * isn't enough to know a route is safe to render: it can be stale (the org
+ * was deleted, or the user was removed from it) after the value round-trips
+ * through storage, so screens must wait for both to settle and confirm the
+ * selected id still resolves to a real membership before mounting forms or
+ * firing mutations with it. Callers still check `organizationId`/`org` for
+ * null themselves (rather than relying on a computed `isValid` flag) so
+ * TypeScript narrows both to non-null after the guard.
+ */
+export function useOrgBoundary() {
+ const { isLoaded } = useOrganization();
+ const { organizationId, role, org, isLoading, isError, isFetching, refetch } = useOrgRole();
+ const isResolving = !isLoaded || isLoading;
+ return { organizationId, role, org, isResolving, isError, isFetching, refetch };
+}
+
export function useOrgWithMembers(organizationId: string | null) {
const trpc = useTRPC();
return useQuery(
diff --git a/apps/mobile/src/lib/hooks/use-persisted-agent-session-filters.ts b/apps/mobile/src/lib/hooks/use-persisted-agent-session-filters.ts
index aa14af1270..40783d3a71 100644
--- a/apps/mobile/src/lib/hooks/use-persisted-agent-session-filters.ts
+++ b/apps/mobile/src/lib/hooks/use-persisted-agent-session-filters.ts
@@ -1,5 +1,6 @@
import * as SecureStore from 'expo-secure-store';
import { useCallback, useEffect, useState } from 'react';
+import { toast } from 'sonner-native';
import { SESSION_FILTERS_KEY } from '@/lib/storage-keys';
@@ -95,7 +96,10 @@ export function usePersistedAgentSessionFilters() {
try {
await SecureStore.setItemAsync(SESSION_FILTERS_KEY, JSON.stringify(filters));
} catch {
- // Keep the in-memory filters even if local preference storage fails.
+ // Keep the in-memory filters so the session still works, but the
+ // change won't survive relaunch — tell the user so it's not a silent
+ // surprise later.
+ toast.error('Could not save setting');
}
};
diff --git a/apps/mobile/src/lib/hooks/use-reviewer-permission.ts b/apps/mobile/src/lib/hooks/use-reviewer-permission.ts
new file mode 100644
index 0000000000..616269b422
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-reviewer-permission.ts
@@ -0,0 +1,50 @@
+import { useQuery } from '@tanstack/react-query';
+import { type Href, useRouter } from 'expo-router';
+import { useEffect } from 'react';
+
+import { classifyPermission, type PermissionState } from '@/lib/code-reviewer-status';
+import { PERSONAL_SCOPE, type ReviewerPlatform } from '@/lib/code-reviewer-config';
+import { useTRPC } from '@/lib/trpc';
+
+function isPersonal(scope: string) {
+ return scope === PERSONAL_SCOPE;
+}
+
+export function useReviewerPermission(scope: string): PermissionState {
+ const trpc = useTRPC();
+ const query = useQuery({
+ ...trpc.organizations.list.queryOptions(),
+ enabled: !isPersonal(scope),
+ });
+ const role = query.data?.find(org => org.organizationId === scope)?.role;
+ return classifyPermission({
+ isPersonal: isPersonal(scope),
+ isLoading: query.isLoading,
+ isError: query.isError,
+ isFetching: query.isFetching,
+ role,
+ refetch: () => void query.refetch(),
+ });
+}
+
+/**
+ * Nested code-reviewer settings routes (instructions/repos/focus-areas/
+ * style/gate) are directly reachable by URL regardless of whether the
+ * overview screen hid the link, so a non-editor landing on one directly
+ * gets bounced back to the overview once their role is known. Mirrors
+ * `useSecurityAgentSettingsRedirect`'s pattern: dep on a derived primitive
+ * (not the raw query/object) and only redirect once resolved to `false`
+ * ('loading' or 'error' render the screen as usual — the mutation itself is
+ * still server-authorized).
+ */
+export function useReviewerEditGuard(scope: string, platform: ReviewerPlatform) {
+ const router = useRouter();
+ const permission = useReviewerPermission(scope);
+ const readOnly = permission.status === 'ready' && !permission.canEdit;
+
+ useEffect(() => {
+ if (readOnly) {
+ router.replace(`/(app)/(tabs)/(3_profile)/code-reviewer/${scope}/${platform}` as Href);
+ }
+ }, [readOnly, router, scope, platform]);
+}
diff --git a/apps/mobile/src/lib/hooks/use-reviewer-route-params.ts b/apps/mobile/src/lib/hooks/use-reviewer-route-params.ts
new file mode 100644
index 0000000000..4221292436
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-reviewer-route-params.ts
@@ -0,0 +1,27 @@
+import { useLocalSearchParams } from 'expo-router';
+
+import { parseReviewerPlatform, type ReviewerPlatform } from '@/lib/code-reviewer-config';
+import { parseParam } from '@/lib/route-params';
+
+/**
+ * Reads and validates the `[scope]/[platform]` route params shared by every
+ * code-reviewer settings screen. Returns `null` when either segment is
+ * missing/malformed or the scope+platform combination isn't supported (see
+ * `parseReviewerPlatform`), so callers can render a single invalid-route
+ * fallback instead of duplicating this parse+guard preamble per screen.
+ */
+export function useValidatedReviewerRouteParams(): {
+ scope: string;
+ platform: ReviewerPlatform;
+} | null {
+ const { scope: rawScope, platform: rawPlatform } = useLocalSearchParams<{
+ scope: string;
+ platform: string;
+ }>();
+ const scope = parseParam(rawScope);
+ const platform = scope ? parseReviewerPlatform(scope, rawPlatform) : null;
+ if (!scope || !platform) {
+ return null;
+ }
+ return { scope, platform };
+}
diff --git a/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts b/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts
new file mode 100644
index 0000000000..c9c5ca54d4
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts
@@ -0,0 +1,192 @@
+import { isPersonalSecurityScope } from '@kilocode/app-shared/security-agent';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { toast } from 'sonner-native';
+
+import { trackSecurityAgentCommand } from '@/lib/hooks/use-security-agent-commands';
+import { type SecurityAgentConfig, type SecurityAgentConfigPatch } from '@/lib/security-agent';
+import { trpcClient, useTRPC } from '@/lib/trpc';
+import { pick } from '@/lib/utils';
+
+// Split out of use-security-agent.ts (mutations only) to stay under the
+// 300-line file limit — these are the write-side hooks, kept alongside the
+// query-key helper they share.
+function useSecurityAgentConfigQueryKey(scope: string) {
+ const trpc = useTRPC();
+ return isPersonalSecurityScope(scope)
+ ? trpc.securityAgent.getConfig.queryKey()
+ : trpc.organizations.securityAgent.getConfig.queryKey({ organizationId: scope });
+}
+
+export function useSaveSecurityAgentConfig(scope: string) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const configQueryKey = useSecurityAgentConfigQueryKey(scope);
+
+ return useMutation({
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ mutationFn: (patch: SecurityAgentConfigPatch) =>
+ isPersonalSecurityScope(scope)
+ ? trpcClient.securityAgent.saveConfig.mutate(patch)
+ : trpcClient.organizations.securityAgent.saveConfig.mutate({
+ organizationId: scope,
+ ...patch,
+ }),
+ onMutate: async patch => {
+ await queryClient.cancelQueries({ queryKey: configQueryKey });
+ const previous = queryClient.getQueryData(configQueryKey);
+ queryClient.setQueryData(configQueryKey, old =>
+ old ? { ...old, ...patch } : old
+ );
+ return { previous, patch };
+ },
+ onError: (error, _patch, context) => {
+ if (context?.previous) {
+ const keys = Object.keys(context.patch) as (keyof SecurityAgentConfigPatch)[];
+ const restoredFields = pick(context.previous, keys);
+ queryClient.setQueryData(configQueryKey, old =>
+ old ? { ...old, ...restoredFields } : old
+ );
+ }
+ toast.error(error.message);
+ },
+ onSuccess: result => {
+ if (result.existingRemediationCommandId) {
+ trackSecurityAgentCommand(queryClient, scope, result.existingRemediationCommandId);
+ }
+ if (result.backlogAdmissionWarning) {
+ toast.error(result.backlogAdmissionWarning);
+ }
+ if (result.remediationBacklogAdmissionWarning) {
+ toast.error(result.remediationBacklogAdmissionWarning);
+ }
+ },
+ onSettled: async () => {
+ await queryClient.invalidateQueries({ queryKey: configQueryKey });
+ if (isPersonalSecurityScope(scope)) {
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: trpc.securityAgent.getDashboardStats.queryKey(),
+ }),
+ queryClient.invalidateQueries({ queryKey: trpc.securityAgent.listFindings.queryKey() }),
+ ]);
+ return;
+ }
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: trpc.organizations.securityAgent.getDashboardStats.queryKey({
+ organizationId: scope,
+ }),
+ }),
+ queryClient.invalidateQueries({
+ queryKey: trpc.organizations.securityAgent.listFindings.queryKey({
+ organizationId: scope,
+ }),
+ }),
+ ]);
+ },
+ });
+}
+
+export function useSetSecurityAgentEnabled(scope: string) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const configQueryKey = useSecurityAgentConfigQueryKey(scope);
+
+ return useMutation({
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ mutationFn: (vars: Parameters[0]) =>
+ isPersonalSecurityScope(scope)
+ ? trpcClient.securityAgent.setEnabled.mutate(vars)
+ : trpcClient.organizations.securityAgent.setEnabled.mutate({
+ organizationId: scope,
+ ...vars,
+ }),
+ onMutate: async vars => {
+ await queryClient.cancelQueries({ queryKey: configQueryKey });
+ const previous = queryClient.getQueryData(configQueryKey);
+ queryClient.setQueryData(configQueryKey, old =>
+ old ? { ...old, isEnabled: vars.isEnabled } : old
+ );
+ return { previous };
+ },
+ onError: (error, _vars, context) => {
+ queryClient.setQueryData(configQueryKey, old =>
+ old && context?.previous ? { ...old, isEnabled: context.previous.isEnabled } : old
+ );
+ toast.error(error.message);
+ },
+ onSuccess: result => {
+ if ('initialSyncAdmissionFailed' in result && result.initialSyncAdmissionFailed) {
+ toast.error(
+ 'Security Agent was enabled, but the initial sync could not be queued. Sync again.'
+ );
+ } else if ('initialSync' in result && result.initialSync) {
+ trackSecurityAgentCommand(queryClient, scope, result.initialSync.commandId);
+ }
+ },
+ onSettled: async () => {
+ if (isPersonalSecurityScope(scope)) {
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: trpc.securityAgent.getPermissionStatus.queryKey(),
+ }),
+ queryClient.invalidateQueries({ queryKey: configQueryKey }),
+ queryClient.invalidateQueries({
+ queryKey: trpc.securityAgent.getRepositories.queryKey(),
+ }),
+ ]);
+ return;
+ }
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: trpc.organizations.securityAgent.getPermissionStatus.queryKey({
+ organizationId: scope,
+ }),
+ }),
+ queryClient.invalidateQueries({ queryKey: configQueryKey }),
+ queryClient.invalidateQueries({
+ queryKey: trpc.organizations.securityAgent.getRepositories.queryKey({
+ organizationId: scope,
+ }),
+ }),
+ ]);
+ },
+ });
+}
+
+export function useTriggerSecuritySync(scope: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ mutationFn: (vars: Parameters[0] = {}) =>
+ isPersonalSecurityScope(scope)
+ ? trpcClient.securityAgent.triggerSync.mutate(vars)
+ : trpcClient.organizations.securityAgent.triggerSync.mutate({
+ organizationId: scope,
+ ...vars,
+ }),
+ onError: error => {
+ toast.error(error.message);
+ },
+ onSuccess: result => {
+ trackSecurityAgentCommand(queryClient, scope, result.commandId);
+ },
+ });
+}
+
+export function useTrackSecurityAgentInteraction(scope: string) {
+ return useMutation({
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ mutationFn: (vars: Parameters[0]) =>
+ isPersonalSecurityScope(scope)
+ ? trpcClient.securityAgent.trackUiInteraction.mutate(vars)
+ : trpcClient.organizations.securityAgent.trackUiInteraction.mutate({
+ organizationId: scope,
+ ...vars,
+ }),
+ onError: error => {
+ toast.error(error.message);
+ },
+ });
+}
diff --git a/apps/mobile/src/lib/hooks/use-security-agent.ts b/apps/mobile/src/lib/hooks/use-security-agent.ts
index af3e3ba323..59c0592785 100644
--- a/apps/mobile/src/lib/hooks/use-security-agent.ts
+++ b/apps/mobile/src/lib/hooks/use-security-agent.ts
@@ -2,16 +2,21 @@ import {
canManageSecurityAgent,
isPersonalSecurityScope,
} from '@kilocode/app-shared/security-agent';
-import { useMutation, useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query';
-import { toast } from 'sonner-native';
-
-import { trackSecurityAgentCommand } from '@/lib/hooks/use-security-agent-commands';
-import {
- type OrganizationRole,
- type SecurityAgentConfig,
- type SecurityAgentConfigPatch,
-} from '@/lib/security-agent';
-import { trpcClient, useTRPC } from '@/lib/trpc';
+import { useQuery, type UseQueryResult } from '@tanstack/react-query';
+
+import { type SecurityAgentConfig } from '@/lib/security-agent';
+import { useTRPC } from '@/lib/trpc';
+
+// Mutation hooks (save config, set enabled, trigger sync, track interaction)
+// live in use-security-agent-mutations.ts — split out to stay under the
+// 300-line file limit. Re-exported here so existing call sites importing
+// from this module are unaffected.
+export {
+ useSaveSecurityAgentConfig,
+ useSetSecurityAgentEnabled,
+ useTrackSecurityAgentInteraction,
+ useTriggerSecuritySync,
+} from '@/lib/hooks/use-security-agent-mutations';
// Personal and org procedures resolve to nominally distinct tRPC option
// types even when structurally identical, so we can't pick between them
@@ -48,13 +53,6 @@ export function useSecurityAgentConfig(scope: string): UseQueryResult;
}
-function useSecurityAgentConfigQueryKey(scope: string) {
- const trpc = useTRPC();
- return isPersonalSecurityScope(scope)
- ? trpc.securityAgent.getConfig.queryKey()
- : trpc.organizations.securityAgent.getConfig.queryKey({ organizationId: scope });
-}
-
export function useSecurityAgentRepositories(scope: string) {
const trpc = useTRPC();
const personal = useQuery({
@@ -104,25 +102,71 @@ export function useSecurityAgentLastSyncTime(scope: string, repoFullName?: strin
// billing_manager can manage config. `organizations.list` is already fetched
// app-wide for the org switcher, so this reuses that cache rather than adding
// a new procedure (mirrors useCanEditReviewer in use-code-reviewer.ts:234).
-export function useSecurityAgentOrgRole(scope: string): OrganizationRole | undefined {
+//
+// A real org-scope fetch failure otherwise collapses into the same
+// `undefined` role as "still loading" and "genuinely unauthorized", which
+// callers used to read as permission-denied. `useSecurityAgentCapability`
+// below tells those apart; it's the only exported consumer of this query.
+function useSecurityAgentOrgRoleQuery(scope: string) {
const trpc = useTRPC();
- const { data: orgs } = useQuery({
+ const isPersonal = isPersonalSecurityScope(scope);
+ const query = useQuery({
...trpc.organizations.list.queryOptions(),
- enabled: !isPersonalSecurityScope(scope),
- });
- return orgs?.find(org => org.organizationId === scope)?.role;
+ enabled: !isPersonal,
+ });
+ if (isPersonal) {
+ // The org list is irrelevant to the personal scope, but even a disabled
+ // observer surfaces the SHARED cache entry's error state (populated
+ // app-wide, e.g. by Profile) — mask it so an organizations.list outage
+ // can never block the personal Security Agent.
+ return {
+ role: undefined,
+ isLoading: false,
+ isError: false,
+ isFetching: false,
+ refetch: query.refetch,
+ };
+ }
+ return {
+ role: query.data?.find(org => org.organizationId === scope)?.role,
+ isLoading: query.isLoading,
+ isError: query.isError,
+ isFetching: query.isFetching,
+ refetch: query.refetch,
+ };
}
-export function useSecurityAgentEditCapability(scope: string): boolean {
- const role = useSecurityAgentOrgRole(scope);
- return canManageSecurityAgent(scope, role);
+// Discriminated capability state for consumers (e.g. audit-report access)
+// that must distinguish "still loading"/"failed to load" from "resolved:
+// no access" instead of treating an undefined role as permission-denied.
+export function useSecurityAgentCapability(scope: string): {
+ canManage: boolean;
+ isLoading: boolean;
+ isError: boolean;
+ isFetching: boolean;
+ refetch: () => unknown;
+} {
+ const { role, isLoading, isError, isFetching, refetch } = useSecurityAgentOrgRoleQuery(scope);
+ return {
+ canManage: canManageSecurityAgent(scope, role),
+ isLoading,
+ isError,
+ isFetching,
+ refetch,
+ };
}
// Reuses listFindings (status: 'open', limit 1) instead of a dedicated
// procedure — the concurrency numbers ride along on every findings fetch.
+// `isLoading`/`isError` are exposed alongside the counts so callers can tell
+// "still loading" and "failed to load" apart from "loaded: capacity full" —
+// all three previously collapsed into the same undefined counts.
export function useSecurityAnalysisCapacity(scope: string): {
runningCount: number | undefined;
concurrencyLimit: number | undefined;
+ isLoading: boolean;
+ isError: boolean;
+ refetch: () => unknown;
} {
const trpc = useTRPC();
const capacityInput = { status: 'open' as const, limit: 1, offset: 0 };
@@ -137,191 +181,12 @@ export function useSecurityAnalysisCapacity(scope: string): {
}),
enabled: !isPersonalSecurityScope(scope),
});
- const data = isPersonalSecurityScope(scope) ? personal.data : organization.data;
- return { runningCount: data?.runningCount, concurrencyLimit: data?.concurrencyLimit };
-}
-
-function pick(
- config: SecurityAgentConfig,
- keys: readonly K[]
-): Pick {
- const result: Partial = {};
- for (const key of keys) {
- result[key] = config[key];
- }
- return result as Pick;
-}
-
-export function useSaveSecurityAgentConfig(scope: string) {
- const trpc = useTRPC();
- const queryClient = useQueryClient();
- const configQueryKey = useSecurityAgentConfigQueryKey(scope);
-
- return useMutation({
- // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
- mutationFn: (patch: SecurityAgentConfigPatch) =>
- isPersonalSecurityScope(scope)
- ? trpcClient.securityAgent.saveConfig.mutate(patch)
- : trpcClient.organizations.securityAgent.saveConfig.mutate({
- organizationId: scope,
- ...patch,
- }),
- onMutate: async patch => {
- await queryClient.cancelQueries({ queryKey: configQueryKey });
- const previous = queryClient.getQueryData(configQueryKey);
- queryClient.setQueryData(configQueryKey, old =>
- old ? { ...old, ...patch } : old
- );
- return { previous, patch };
- },
- onError: (error, _patch, context) => {
- if (context?.previous) {
- const keys = Object.keys(context.patch) as (keyof SecurityAgentConfigPatch)[];
- const restoredFields = pick(context.previous, keys);
- queryClient.setQueryData(configQueryKey, old =>
- old ? { ...old, ...restoredFields } : old
- );
- }
- toast.error(error.message);
- },
- onSuccess: result => {
- if (result.existingRemediationCommandId) {
- trackSecurityAgentCommand(queryClient, scope, result.existingRemediationCommandId);
- }
- if (result.backlogAdmissionWarning) {
- toast.error(result.backlogAdmissionWarning);
- }
- if (result.remediationBacklogAdmissionWarning) {
- toast.error(result.remediationBacklogAdmissionWarning);
- }
- },
- onSettled: async () => {
- await queryClient.invalidateQueries({ queryKey: configQueryKey });
- if (isPersonalSecurityScope(scope)) {
- await Promise.all([
- queryClient.invalidateQueries({
- queryKey: trpc.securityAgent.getDashboardStats.queryKey(),
- }),
- queryClient.invalidateQueries({ queryKey: trpc.securityAgent.listFindings.queryKey() }),
- ]);
- return;
- }
- await Promise.all([
- queryClient.invalidateQueries({
- queryKey: trpc.organizations.securityAgent.getDashboardStats.queryKey({
- organizationId: scope,
- }),
- }),
- queryClient.invalidateQueries({
- queryKey: trpc.organizations.securityAgent.listFindings.queryKey({
- organizationId: scope,
- }),
- }),
- ]);
- },
- });
-}
-
-export function useSetSecurityAgentEnabled(scope: string) {
- const trpc = useTRPC();
- const queryClient = useQueryClient();
- const configQueryKey = useSecurityAgentConfigQueryKey(scope);
-
- return useMutation({
- // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
- mutationFn: (vars: Parameters[0]) =>
- isPersonalSecurityScope(scope)
- ? trpcClient.securityAgent.setEnabled.mutate(vars)
- : trpcClient.organizations.securityAgent.setEnabled.mutate({
- organizationId: scope,
- ...vars,
- }),
- onMutate: async vars => {
- await queryClient.cancelQueries({ queryKey: configQueryKey });
- const previous = queryClient.getQueryData(configQueryKey);
- queryClient.setQueryData(configQueryKey, old =>
- old ? { ...old, isEnabled: vars.isEnabled } : old
- );
- return { previous };
- },
- onError: (error, _vars, context) => {
- queryClient.setQueryData(configQueryKey, old =>
- old && context?.previous ? { ...old, isEnabled: context.previous.isEnabled } : old
- );
- toast.error(error.message);
- },
- onSuccess: result => {
- if ('initialSyncAdmissionFailed' in result && result.initialSyncAdmissionFailed) {
- toast.error(
- 'Security Agent was enabled, but the initial sync could not be queued. Sync again.'
- );
- } else if ('initialSync' in result && result.initialSync) {
- trackSecurityAgentCommand(queryClient, scope, result.initialSync.commandId);
- }
- },
- onSettled: async () => {
- if (isPersonalSecurityScope(scope)) {
- await Promise.all([
- queryClient.invalidateQueries({
- queryKey: trpc.securityAgent.getPermissionStatus.queryKey(),
- }),
- queryClient.invalidateQueries({ queryKey: configQueryKey }),
- queryClient.invalidateQueries({
- queryKey: trpc.securityAgent.getRepositories.queryKey(),
- }),
- ]);
- return;
- }
- await Promise.all([
- queryClient.invalidateQueries({
- queryKey: trpc.organizations.securityAgent.getPermissionStatus.queryKey({
- organizationId: scope,
- }),
- }),
- queryClient.invalidateQueries({ queryKey: configQueryKey }),
- queryClient.invalidateQueries({
- queryKey: trpc.organizations.securityAgent.getRepositories.queryKey({
- organizationId: scope,
- }),
- }),
- ]);
- },
- });
-}
-
-export function useTriggerSecuritySync(scope: string) {
- const queryClient = useQueryClient();
-
- return useMutation({
- // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
- mutationFn: (vars: Parameters[0] = {}) =>
- isPersonalSecurityScope(scope)
- ? trpcClient.securityAgent.triggerSync.mutate(vars)
- : trpcClient.organizations.securityAgent.triggerSync.mutate({
- organizationId: scope,
- ...vars,
- }),
- onError: error => {
- toast.error(error.message);
- },
- onSuccess: result => {
- trackSecurityAgentCommand(queryClient, scope, result.commandId);
- },
- });
-}
-
-export function useTrackSecurityAgentInteraction(scope: string) {
- return useMutation({
- // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
- mutationFn: (vars: Parameters[0]) =>
- isPersonalSecurityScope(scope)
- ? trpcClient.securityAgent.trackUiInteraction.mutate(vars)
- : trpcClient.organizations.securityAgent.trackUiInteraction.mutate({
- organizationId: scope,
- ...vars,
- }),
- onError: error => {
- toast.error(error.message);
- },
- });
+ const active = isPersonalSecurityScope(scope) ? personal : organization;
+ return {
+ runningCount: active.data?.runningCount,
+ concurrencyLimit: active.data?.concurrencyLimit,
+ isLoading: active.isLoading,
+ isError: active.isError,
+ refetch: active.refetch,
+ };
}
diff --git a/apps/mobile/src/lib/hooks/use-security-findings.ts b/apps/mobile/src/lib/hooks/use-security-findings.ts
index 39802e89ff..d22ceac9b9 100644
--- a/apps/mobile/src/lib/hooks/use-security-findings.ts
+++ b/apps/mobile/src/lib/hooks/use-security-findings.ts
@@ -91,6 +91,9 @@ export function useSecurityAnalysis(scope: string, findingId: string) {
return isPersonalSecurityScope(scope) ? personal : organization;
}
+// No hook-level onError toast: dismiss-finding-screen.tsx is the sole caller
+// and is a form sheet that stays open on failure — it renders
+// `dismissFinding.isError` inline above the confirm button instead (P2).
export function useDismissSecurityFinding(scope: string) {
const queryClient = useQueryClient();
return useMutation({
@@ -102,9 +105,6 @@ export function useDismissSecurityFinding(scope: string) {
organizationId: scope,
...vars,
}),
- onError: error => {
- toast.error(error.message);
- },
onSuccess: result => {
trackSecurityAgentCommand(queryClient, scope, result.commandId);
},
@@ -267,9 +267,22 @@ export function useRetrySecurityRemediation(scope: string) {
});
}
+function getSecurityAnalysisQueryKey(
+ trpc: ReturnType,
+ scope: string,
+ findingId: string
+) {
+ return isPersonalSecurityScope(scope)
+ ? trpc.securityAgent.getAnalysis.queryKey({ findingId })
+ : trpc.organizations.securityAgent.getAnalysis.queryKey({ organizationId: scope, findingId });
+}
+
// cancelRemediation resolves synchronously (no background command to track),
// so — unlike start/retry — we invalidate the affected queries ourselves
-// once the immediate result comes back.
+// once the immediate result comes back. Reuses invalidateRemediationQueries
+// (the same helper start/retry use) so the analysis query — the one that
+// actually owns remediationAttempts — is never left stale, which the
+// hand-rolled invalidation list here used to miss.
export function useCancelSecurityRemediation(scope: string) {
const trpc = useTRPC();
const queryClient = useQueryClient();
@@ -282,40 +295,44 @@ export function useCancelSecurityRemediation(scope: string) {
organizationId: scope,
attemptId: vars.attemptId,
}),
- onError: error => {
- toast.error(error.message);
+ onMutate: async vars => {
+ const analysisQueryKey = getSecurityAnalysisQueryKey(trpc, scope, vars.findingId);
+ await queryClient.cancelQueries({ queryKey: analysisQueryKey });
+ const previous = queryClient.getQueryData(analysisQueryKey);
+ queryClient.setQueryData(analysisQueryKey, old =>
+ old
+ ? {
+ ...old,
+ remediationAttempts: old.remediationAttempts.map(attempt =>
+ attempt.id === vars.attemptId
+ ? { ...attempt, cancellationRequestedAt: new Date().toISOString() }
+ : attempt
+ ),
+ }
+ : old
+ );
+ return { previous, analysisQueryKey };
},
- onSuccess: async (_result, vars) => {
- if (isPersonalSecurityScope(scope)) {
- await Promise.all([
- queryClient.invalidateQueries({
- queryKey: trpc.securityAgent.getFinding.queryKey({ id: vars.findingId }),
- }),
- queryClient.invalidateQueries({ queryKey: trpc.securityAgent.listFindings.queryKey() }),
- queryClient.invalidateQueries({
- queryKey: trpc.securityAgent.getDashboardStats.queryKey(),
- }),
- ]);
- return;
+ onError: (error, _vars, context) => {
+ if (context?.previous) {
+ queryClient.setQueryData(context.analysisQueryKey, context.previous);
}
- await Promise.all([
- queryClient.invalidateQueries({
- queryKey: trpc.organizations.securityAgent.getFinding.queryKey({
- organizationId: scope,
- id: vars.findingId,
- }),
- }),
- queryClient.invalidateQueries({
- queryKey: trpc.organizations.securityAgent.listFindings.queryKey({
- organizationId: scope,
- }),
- }),
- queryClient.invalidateQueries({
- queryKey: trpc.organizations.securityAgent.getDashboardStats.queryKey({
- organizationId: scope,
- }),
- }),
- ]);
+ toast.error(error.message);
+ },
+ onSuccess: result => {
+ // 'cancellation_requested' means the attempt was already running and
+ // was only asked to stop — it may still finish and produce a PR.
+ toast.success(
+ result.status === 'cancellation_requested'
+ ? 'Cancellation requested'
+ : 'Remediation cancelled'
+ );
+ },
+ onSettled: async (_result, _error, vars) => {
+ await invalidateRemediationQueries(
+ { trpc, queryClient },
+ { scope, findingId: vars.findingId }
+ );
},
});
}
diff --git a/apps/mobile/src/lib/hooks/use-sentry-consent-sync.ts b/apps/mobile/src/lib/hooks/use-sentry-consent-sync.ts
new file mode 100644
index 0000000000..be7e74a72f
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-sentry-consent-sync.ts
@@ -0,0 +1,28 @@
+import { useEffect, useRef } from 'react';
+
+import { reinitSentryForConsent } from '@/lib/sentry-consent';
+
+/**
+ * Applies the settled consent state to Sentry via close-then-init
+ * transitions (see reinitSentryForConsent for why 7.x needs a full
+ * teardown).
+ */
+export function useSentryConsentSync(consented: boolean, init: (consented: boolean) => void) {
+ // Starts `false` because module scope already ran init(false).
+ const appliedRef = useRef(false);
+
+ useEffect(() => {
+ if (appliedRef.current === consented) {
+ return;
+ }
+ appliedRef.current = consented;
+ void reinitSentryForConsent(consented, init, () => {
+ // Failed transition (close or init threw): the old client may still be
+ // live, so un-mark this consent state — the next consent change
+ // re-attempts a full close+init instead of being skipped as a no-op.
+ if (appliedRef.current === consented) {
+ appliedRef.current = !consented;
+ }
+ });
+ }, [consented, init]);
+}
diff --git a/apps/mobile/src/lib/hooks/use-session-mutations.ts b/apps/mobile/src/lib/hooks/use-session-mutations.ts
index dd3e7d303c..075040c224 100644
--- a/apps/mobile/src/lib/hooks/use-session-mutations.ts
+++ b/apps/mobile/src/lib/hooks/use-session-mutations.ts
@@ -1,9 +1,17 @@
-import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { type QueryKey, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner-native';
import { invalidateAgentSessionQueries } from '@/lib/agent-session-cache';
+import { chainSave } from '@/lib/hooks/save-chain';
+import {
+ mapStoredSessions,
+ removeStoredSession,
+ type SessionsListData,
+} from '@/lib/session-list-cache';
import { useTRPC } from '@/lib/trpc';
+type SessionsListSnapshot = [QueryKey, SessionsListData | undefined][];
+
const onError = (error: { message: string }) => {
toast.error(error.message || 'Something went wrong');
};
@@ -11,31 +19,86 @@ const onError = (error: { message: string }) => {
export function useSessionMutations() {
const trpc = useTRPC();
const queryClient = useQueryClient();
+ const listKey = trpc.cliSessionsV2.list.infiniteQueryKey();
const invalidateSessions = async () => {
await invalidateAgentSessionQueries(queryClient, trpc);
};
+ const snapshotAndUpdate = async (update: (data: SessionsListData) => SessionsListData) => {
+ await queryClient.cancelQueries({ queryKey: listKey });
+ const previous = queryClient.getQueriesData({ queryKey: listKey });
+ queryClient.setQueriesData({ queryKey: listKey }, old =>
+ old ? update(old) : old
+ );
+ return { previous };
+ };
+
+ const rollback = (previous?: SessionsListSnapshot) => {
+ for (const [key, data] of previous ?? []) {
+ queryClient.setQueryData(key, data);
+ }
+ };
+
const deleteSessionMutation = useMutation(
trpc.cliSessionsV2.delete.mutationOptions({
- onSuccess: invalidateSessions,
- onError,
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ onMutate: ({ session_id }) =>
+ snapshotAndUpdate(data => removeStoredSession(data, session_id)),
+ onError: (error, _input, context) => {
+ rollback(context?.previous);
+ onError(error);
+ },
+ onSettled: invalidateSessions,
})
);
const renameSessionMutation = useMutation(
trpc.cliSessionsV2.rename.mutationOptions({
- onSuccess: invalidateSessions,
- onError,
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ onMutate: ({ session_id, title }) =>
+ snapshotAndUpdate(data =>
+ mapStoredSessions(data, session_id, session => ({ ...session, title }))
+ ),
+ onError: (error, _input, context) => {
+ rollback(context?.previous);
+ onError(error);
+ },
+ onSettled: invalidateSessions,
})
);
+ // Per session row: DISTINCT operations (a delete during a settling rename,
+ // a re-rename to a different title) run serialized through a per-session
+ // chain (chainSave, see save-chain.ts) so their optimistic
+ // snapshots/rollbacks can't interleave and an older request can never
+ // overwrite a newer one's result. Rename goes through a modal confirm and
+ // delete through Alert.alert, so an adjacent double-fire of the same op
+ // is already impossible — no dedupe needed here.
return {
deleteSession: (sessionId: string) => {
- deleteSessionMutation.mutate({ session_id: sessionId });
+ void (async () => {
+ try {
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ await chainSave(sessionId, () =>
+ deleteSessionMutation.mutateAsync({ session_id: sessionId })
+ );
+ } catch {
+ // Already surfaced via the mutation's own onError (toast + rollback).
+ }
+ })();
},
renameSession: (sessionId: string, title: string) => {
- renameSessionMutation.mutate({ session_id: sessionId, title });
+ void (async () => {
+ try {
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ await chainSave(sessionId, () =>
+ renameSessionMutation.mutateAsync({ session_id: sessionId, title })
+ );
+ } catch {
+ // Already surfaced via the mutation's own onError (toast + rollback).
+ }
+ })();
},
};
}
diff --git a/apps/mobile/src/lib/hooks/use-settings-back-guard.ts b/apps/mobile/src/lib/hooks/use-settings-back-guard.ts
index 695fc8e65f..a44e03ea5e 100644
--- a/apps/mobile/src/lib/hooks/use-settings-back-guard.ts
+++ b/apps/mobile/src/lib/hooks/use-settings-back-guard.ts
@@ -54,9 +54,22 @@ export function useSettingsBackGuard({
const onSaveRef = useRef(onSave);
onSaveRef.current = onSave;
+ // Set by SettingsSaveButton right before it calls `router.back()` after a
+ // successful header-Save. `dirty` won't flip to false until this screen
+ // re-hydrates from a refetched query — which hasn't happened yet at that
+ // point — so without this bypass the back navigation the Save button
+ // itself triggers gets intercepted as if it were an unconfirmed exit,
+ // popping a spurious "Unsaved changes" alert whose own Save button would
+ // save a second time.
+ const skipNextGuardRef = useRef(false);
+
useEffect(
() =>
navigation.addListener('beforeRemove', event => {
+ if (skipNextGuardRef.current) {
+ skipNextGuardRef.current = false;
+ return;
+ }
if (!dirty) {
return;
}
@@ -100,5 +113,6 @@ export function useSettingsBackGuard({
onBack: () => {
navigation.goBack();
},
+ skipNextGuardRef,
};
}
diff --git a/apps/mobile/src/lib/hooks/use-theme-colors.ts b/apps/mobile/src/lib/hooks/use-theme-colors.ts
index 2b23187c5a..5d9ae9d8f6 100644
--- a/apps/mobile/src/lib/hooks/use-theme-colors.ts
+++ b/apps/mobile/src/lib/hooks/use-theme-colors.ts
@@ -11,8 +11,8 @@ const lightColors = {
secondary: '#F0EEE6',
secondaryForeground: '#14130F',
muted: '#F0EEE6',
- mutedForeground: '#7A756B',
- destructive: '#C25647',
+ mutedForeground: '#6F6A61',
+ destructive: '#BE4E3F',
border: 'rgba(20, 15, 10, 0.09)',
card: '#FFFFFF',
@@ -22,8 +22,9 @@ const lightColors = {
hairSoft: 'rgba(20, 15, 10, 0.05)',
accentSoft: '#E8F27A',
accentSoftForeground: '#1A1A10',
- good: '#2F9A5F',
- warn: '#B27214',
+ good: '#278150',
+ warn: '#9F6612',
+ info: '#2563EB',
// Per-agent hues (full-opacity only — tile bg/border live in CSS tokens)
agentYuki: '#6B4FD6',
@@ -55,6 +56,7 @@ const darkColors = {
accentSoftForeground: '#1A1A10',
good: '#5FCB8E',
warn: '#F2B05F',
+ info: '#60A5FA',
// Per-agent hues
agentYuki: '#A78BFA',
diff --git a/apps/mobile/src/lib/integration-urls.test.ts b/apps/mobile/src/lib/integration-urls.test.ts
index 18a0a5f98c..ed551a3d6a 100644
--- a/apps/mobile/src/lib/integration-urls.test.ts
+++ b/apps/mobile/src/lib/integration-urls.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { getGitLabIntegrationUrl } from './integration-urls';
+import { getBitbucketIntegrationUrl, getGitLabIntegrationUrl } from './integration-urls';
describe('getGitLabIntegrationUrl', () => {
it('builds personal and organization GitLab integration URLs', () => {
@@ -12,3 +12,14 @@ describe('getGitLabIntegrationUrl', () => {
);
});
});
+
+describe('getBitbucketIntegrationUrl', () => {
+ it('links to the org code-reviews page with the Bitbucket tab selected', () => {
+ expect(getBitbucketIntegrationUrl('https://app.kilo.ai', 'org_123')).toBe(
+ 'https://app.kilo.ai/organizations/org_123/code-reviews?platform=bitbucket'
+ );
+ expect(getBitbucketIntegrationUrl('https://app.kilo.ai/', 'org_123')).toBe(
+ 'https://app.kilo.ai/organizations/org_123/code-reviews?platform=bitbucket'
+ );
+ });
+});
diff --git a/apps/mobile/src/lib/integration-urls.ts b/apps/mobile/src/lib/integration-urls.ts
index c50998fcf1..cabfe28c6d 100644
--- a/apps/mobile/src/lib/integration-urls.ts
+++ b/apps/mobile/src/lib/integration-urls.ts
@@ -5,3 +5,12 @@ export function getGitLabIntegrationUrl(webBaseUrl: string, organizationId?: str
}
return `${baseUrl}/organizations/${encodeURIComponent(organizationId)}/integrations/gitlab`;
}
+
+// Bitbucket is org-only (see PLATFORM_CAPABILITIES), so unlike the GitHub/
+// GitLab helpers above there is no personal variant — it links straight to
+// the org's Code Reviewer settings page (apps/web's
+// organizations/[id]/code-reviews), pre-selecting the Bitbucket tab.
+export function getBitbucketIntegrationUrl(webBaseUrl: string, organizationId: string): string {
+ const baseUrl = webBaseUrl.endsWith('/') ? webBaseUrl.slice(0, -1) : webBaseUrl;
+ return `${baseUrl}/organizations/${encodeURIComponent(organizationId)}/code-reviews?platform=bitbucket`;
+}
diff --git a/apps/mobile/src/lib/kilo-chat-routes.ts b/apps/mobile/src/lib/kilo-chat-routes.ts
index 5be5a64d74..57f35cf2a8 100644
--- a/apps/mobile/src/lib/kilo-chat-routes.ts
+++ b/apps/mobile/src/lib/kilo-chat-routes.ts
@@ -18,12 +18,6 @@ export function chatConversationPath(sandboxId: string, conversationId: string):
return chatConversationRoute(sandboxId, conversationId) as Href;
}
-export function chatRenameConversationPath(sandboxId: string, params: URLSearchParams): Href {
- const renameParams = new URLSearchParams(params);
- renameParams.set('sandboxId', sandboxId);
- return `/(app)/(tabs)/(1_kiloclaw)/rename-conversation?${renameParams.toString()}` as Href;
-}
-
export function chatInstancePickerPath(currentId: string): Href {
return `${KILOCLAW_TAB_CHAT_ROOT}/instance-picker?currentId=${currentId}` as Href;
}
diff --git a/apps/mobile/src/lib/kilo-pass/subscription-card-content-state.test.ts b/apps/mobile/src/lib/kilo-pass/subscription-card-content-state.test.ts
index 7a82c8a63c..18610a4173 100644
--- a/apps/mobile/src/lib/kilo-pass/subscription-card-content-state.test.ts
+++ b/apps/mobile/src/lib/kilo-pass/subscription-card-content-state.test.ts
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest';
+import { formatDate, parseTimestamp } from '@/lib/utils';
+
import { getKiloPassSubscriptionCardContentState } from './subscription-card-state';
const activeAppStoreSubscription = {
@@ -10,6 +12,8 @@ const activeAppStoreSubscription = {
status: 'active',
};
+const endDate = formatDate(parseTimestamp(activeAppStoreSubscription.refillAt));
+
describe('getKiloPassSubscriptionCardContentState', () => {
it('keeps unresolved iOS state non-actionable while loading', () => {
expect(
@@ -169,7 +173,7 @@ describe('getKiloPassSubscriptionCardContentState', () => {
state: {
action: 'open-store-management',
actionLabel: 'Manage',
- description: '$19 monthly credits · Ends June 8, 2026',
+ description: `$19 monthly credits · Ends ${endDate}`,
title: 'Kilo Pass canceling',
},
});
@@ -212,7 +216,7 @@ describe('getKiloPassSubscriptionCardContentState', () => {
state: {
action: 'none',
actionLabel: null,
- description: '$19 monthly credits · Ends June 8, 2026 · Managed in App Store',
+ description: `$19 monthly credits · Ends ${endDate} · Managed in App Store`,
title: 'Kilo Pass canceling',
},
});
diff --git a/apps/mobile/src/lib/kilo-pass/subscription-card-state.test.ts b/apps/mobile/src/lib/kilo-pass/subscription-card-state.test.ts
index 99a2a0644c..6b170d84ce 100644
--- a/apps/mobile/src/lib/kilo-pass/subscription-card-state.test.ts
+++ b/apps/mobile/src/lib/kilo-pass/subscription-card-state.test.ts
@@ -1,11 +1,15 @@
import { describe, expect, it } from 'vitest';
+import { formatDate, parseTimestamp } from '@/lib/utils';
+
import {
getKiloPassSubscriptionCardAccessibility,
getKiloPassSubscriptionCardState,
shouldRenderKiloPassSubscriptionCard,
} from './subscription-card-state';
+const endDate = formatDate(parseTimestamp('2026-06-08T15:21:05.000Z'));
+
describe('getKiloPassSubscriptionCardState', () => {
it('sends Stripe-managed Kilo Pass users to web management', () => {
expect(
@@ -59,7 +63,7 @@ describe('getKiloPassSubscriptionCardState', () => {
).toEqual({
action: 'none',
actionLabel: null,
- description: '$49 monthly credits · Ends June 8, 2026 · This Kilo Pass is managed on web',
+ description: `$49 monthly credits · Ends ${endDate} · This Kilo Pass is managed on web`,
title: 'Kilo Pass canceling',
});
});
@@ -108,7 +112,7 @@ describe('getKiloPassSubscriptionCardState', () => {
).toEqual({
action: 'open-store-management',
actionLabel: 'Manage',
- description: '$19 monthly credits · Ends June 8, 2026',
+ description: `$19 monthly credits · Ends ${endDate}`,
title: 'Kilo Pass canceling',
});
});
@@ -148,7 +152,7 @@ describe('getKiloPassSubscriptionCardState', () => {
).toEqual({
action: 'none',
actionLabel: null,
- description: '$19 monthly credits · Ends June 8, 2026 · Managed in App Store',
+ description: `$19 monthly credits · Ends ${endDate} · Managed in App Store`,
title: 'Kilo Pass canceling',
});
});
@@ -182,7 +186,7 @@ describe('getKiloPassSubscriptionCardState', () => {
).toEqual({
action: 'none',
actionLabel: null,
- description: '$49 monthly credits · Ends June 8, 2026 · Managed on Google Play',
+ description: `$49 monthly credits · Ends ${endDate} · Managed on Google Play`,
title: 'Kilo Pass canceling',
});
});
@@ -196,7 +200,7 @@ describe('getKiloPassSubscriptionCardState', () => {
refillAt: '2026-06-08 15:21:05+00',
status: 'active',
}).description
- ).toContain('June 8, 2026');
+ ).toContain(endDate);
});
it('treats canceled App Store-managed subscriptions as unsubscribed', () => {
diff --git a/apps/mobile/src/lib/kilo-pass/subscription-card-state.ts b/apps/mobile/src/lib/kilo-pass/subscription-card-state.ts
index 1c526f7d8c..6036f90a9f 100644
--- a/apps/mobile/src/lib/kilo-pass/subscription-card-state.ts
+++ b/apps/mobile/src/lib/kilo-pass/subscription-card-state.ts
@@ -1,4 +1,4 @@
-import { parseTimestamp } from '@/lib/utils';
+import { formatDate, parseTimestamp } from '@/lib/utils';
type KiloPassSubscriptionCardSubscription = {
cancelAtPeriodEnd: boolean;
@@ -162,11 +162,7 @@ function formatSubscriptionEndDate(iso: string | null): string {
return 'period end';
}
- return date.toLocaleDateString('en-US', {
- month: 'long',
- day: 'numeric',
- year: 'numeric',
- });
+ return formatDate(date);
}
function isEndedSubscriptionStatus(status: string): boolean {
diff --git a/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-products.ts b/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-products.ts
index b5cfed347b..a173fe3ad2 100644
--- a/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-products.ts
+++ b/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-products.ts
@@ -2,17 +2,18 @@ import { useCallback, useEffect, useState } from 'react';
import { Platform } from 'react-native';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { fetchProducts as fetchIapProducts, type ProductOrSubscription, useIAP } from 'expo-iap';
-import { toast } from 'sonner-native';
import { useTRPC } from '@/lib/trpc';
import { type StoreKiloPassProduct } from './store-products';
import { getStoreKiloPassProductsState } from './store-products-state';
-import {
- loadAppStoreKiloPassProducts,
- NO_MATCHING_KILO_PASS_PRODUCTS_MESSAGE,
-} from './store-products-loader';
+import { loadAppStoreKiloPassProducts } from './store-products-loader';
const STORE_KILO_PASS_PRODUCTS_STALE_TIME_MS = 5 * 60 * 1000;
+// Fixed bound on the App Store connection handshake — raise if real
+// devices routinely need longer than this to connect.
+const APP_STORE_CONNECTION_TIMEOUT_MS = 8000;
+const APP_STORE_CONNECTION_TIMEOUT_MESSAGE =
+ 'Could not connect to the App Store. Check your connection and try again.';
function toStoreKiloPassProduct(product: ProductOrSubscription): StoreKiloPassProduct | null {
if (product.type !== 'subs') {
@@ -48,15 +49,28 @@ export function useStoreKiloPassProducts() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const [storeErrorMessage, setStoreErrorMessage] = useState(null);
+ const [connectionAttempt, setConnectionAttempt] = useState(0);
const { connected } = useIAP({
onError: error => {
- const message = error.message;
- setStoreErrorMessage(message);
- toast.error(message);
+ setStoreErrorMessage(error.message);
},
});
+ // Bounded wait for the StoreKit connection — without this, a stuck
+ // connection leaves the screen showing loading skeletons forever.
+ useEffect(() => {
+ if (Platform.OS !== 'ios' || connected) {
+ return undefined;
+ }
+ const timer = setTimeout(() => {
+ setStoreErrorMessage(current => current ?? APP_STORE_CONNECTION_TIMEOUT_MESSAGE);
+ }, APP_STORE_CONNECTION_TIMEOUT_MS);
+ return () => {
+ clearTimeout(timer);
+ };
+ }, [connected, connectionAttempt]);
+
const productsQuery = useQuery({
queryKey: ['kilo-pass', 'app-store-products'],
queryFn: async () => {
@@ -78,18 +92,13 @@ export function useStoreKiloPassProducts() {
const { refetch: refetchProducts } = productsQuery;
const refetch = useCallback(async () => {
setStoreErrorMessage(null);
+ setConnectionAttempt(attempt => attempt + 1);
await refetchProducts();
}, [refetchProducts]);
const queryErrorMessage =
productsQuery.error instanceof Error ? productsQuery.error.message : null;
- useEffect(() => {
- if (queryErrorMessage && queryErrorMessage !== NO_MATCHING_KILO_PASS_PRODUCTS_MESSAGE) {
- toast.error(queryErrorMessage);
- }
- }, [queryErrorMessage]);
-
useEffect(() => {
if (productsQuery.isSuccess) {
setStoreErrorMessage(null);
diff --git a/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.test.tsx b/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.test.tsx
index b9d91b26ce..b1734c59d7 100644
--- a/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.test.tsx
+++ b/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.test.tsx
@@ -6,7 +6,10 @@ import { toast } from 'sonner-native';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
createAppStoreKiloPassPurchaseActions,
+ resetInlinePurchaseErrorOwnership,
+ resetPurchaseErrorToastDedup,
StoreKiloPassPurchaseProvider,
+ useInlinePurchaseErrorOwnership,
} from './use-store-kilo-pass-purchase';
import { type AppStoreKiloPassProduct } from './store-products';
@@ -106,6 +109,7 @@ type StoreKiloPassPurchaseContextValue = {
restorePurchases: () => Promise<'restored' | 'empty' | 'failed'>;
isPending: boolean;
isRestoringPurchases: boolean;
+ errorMessage: string | null;
};
type ReactInternals = {
@@ -192,6 +196,36 @@ function renderStoreKiloPassPurchaseProvider() {
return { render };
}
+/** Mounts `useInlinePurchaseErrorOwnership`, returning an `unmount` that runs its cleanup. */
+function mountInlineErrorOwnership() {
+ const reactInternals = React as typeof React & ReactInternals;
+ let cleanup: (() => void) | undefined = undefined;
+ const dispatcher = {
+ useEffect: (effect: () => (() => void) | undefined) => {
+ cleanup = effect();
+ },
+ };
+
+ const previousDispatcher =
+ reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H;
+ reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H = dispatcher;
+ try {
+ // Same alias trick as renderProviderElement above: run the hook against
+ // the fake dispatcher without tripping rules-of-hooks lexically.
+ const mountOwnershipHook = useInlinePurchaseErrorOwnership;
+ mountOwnershipHook();
+ } finally {
+ reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H =
+ previousDispatcher;
+ }
+
+ return {
+ unmount: () => {
+ cleanup?.();
+ },
+ };
+}
+
function ignoreDeferredResolution(_value: unknown) {
return undefined;
}
@@ -271,6 +305,8 @@ function createPurchase(overrides: Partial = {}): Purchase {
beforeEach(() => {
vi.clearAllMocks();
+ resetPurchaseErrorToastDedup();
+ resetInlinePurchaseErrorOwnership();
mockedIap.availablePurchases = [];
mockedIap.connected = false;
mockedIap.finishTransaction.mockResolvedValue(undefined);
@@ -816,6 +852,38 @@ describe('StoreKiloPassPurchaseProvider', () => {
expect(mockedIap.requestPurchase).toHaveBeenCalledTimes(2);
});
+ it('toasts a purchase error when no screen owns inline feedback', async () => {
+ const provider = renderStoreKiloPassPurchaseProvider();
+
+ const initialValue = provider.render();
+ await initialValue.purchase(product);
+
+ mockedIap.handlers?.onPurchaseError(new Error('Untoasted screen check failed'));
+ const releasedValue = provider.render();
+
+ expect(toast.error).toHaveBeenCalledWith('Untoasted screen check failed');
+ expect(releasedValue.errorMessage).toBe('Untoasted screen check failed');
+ });
+
+ it('suppresses the purchase-error toast while a screen owns inline feedback, and resumes once it unmounts', async () => {
+ const provider = renderStoreKiloPassPurchaseProvider();
+ const owner = mountInlineErrorOwnership();
+
+ const initialValue = provider.render();
+ await initialValue.purchase(product);
+ mockedIap.handlers?.onPurchaseError(new Error('Inline banner check failed'));
+ const ownedValue = provider.render();
+
+ expect(toast.error).not.toHaveBeenCalled();
+ expect(ownedValue.errorMessage).toBe('Inline banner check failed');
+
+ owner.unmount();
+ await ownedValue.purchase(product);
+ mockedIap.handlers?.onPurchaseError(new Error('Untoasted screen check failed'));
+
+ expect(toast.error).toHaveBeenCalledWith('Untoasted screen check failed');
+ });
+
it('ignores live StoreKit success for an unknown product', async () => {
const onCompleted = vi.fn();
const provider = renderStoreKiloPassPurchaseProvider();
diff --git a/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.ts b/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.ts
index 6d0a0a3af1..783dd6a31f 100644
--- a/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.ts
+++ b/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.ts
@@ -79,7 +79,7 @@ type StoreKiloPassPurchaseOptions = {
onCompleted?: () => void;
};
-type StoreKiloPassRestorePurchasesResult = 'restored' | 'empty' | 'failed';
+export type StoreKiloPassRestorePurchasesResult = 'restored' | 'empty' | 'failed';
type StoreKiloPassPurchaseContextValue = {
appStoreOwnershipPreflight: AppStoreKiloPassOwnershipPreflight;
@@ -90,6 +90,9 @@ type StoreKiloPassPurchaseContextValue = {
restorePurchases: () => Promise;
isPending: boolean;
isRestoringPurchases: boolean;
+ /** Last purchase/restore failure message, for screens that render it inline. */
+ errorMessage: string | null;
+ clearError: () => void;
};
const StoreKiloPassPurchaseContext = createContext(null);
@@ -104,6 +107,25 @@ export function resetPurchaseErrorToastDedup() {
lastPurchaseErrorToast = null;
}
+// Screens that render `errorMessage` inline (e.g. the subscription screen)
+// register ownership on mount so purchase/restore failures don't also pop a
+// toast behind them. Counter (not a boolean) so it degrades safely if more
+// than one owner is ever mounted at once.
+let inlineErrorOwnerCount = 0;
+
+export function resetInlinePurchaseErrorOwnership() {
+ inlineErrorOwnerCount = 0;
+}
+
+export function useInlinePurchaseErrorOwnership() {
+ useEffect(() => {
+ inlineErrorOwnerCount += 1;
+ return () => {
+ inlineErrorOwnerCount -= 1;
+ };
+ }, []);
+}
+
type PurchaseCompletionOptions = {
invalidateAfterCompletion?: boolean;
notifyErrors?: boolean;
@@ -171,6 +193,10 @@ function getKiloPassPurchaseErrorMessage(error: unknown, fallback: string): stri
}
function showDedupedPurchaseError(message: string) {
+ if (inlineErrorOwnerCount > 0) {
+ return;
+ }
+
const now = Date.now();
if (
lastPurchaseErrorToast?.message === message &&
@@ -344,6 +370,10 @@ export function StoreKiloPassPurchaseProvider({ children }: { children: ReactNod
const queryClient = useQueryClient();
const [isRequestingPurchase, setIsRequestingPurchase] = useState(false);
const [isRestoringPurchases, setIsRestoringPurchases] = useState(false);
+ const [errorMessage, setErrorMessage] = useState(null);
+ const clearError = useCallback(() => {
+ setErrorMessage(null);
+ }, []);
const recoveredPurchaseIdsRef = useRef(new Set());
const recoveryInFlightPurchaseIdsRef = useRef(new Set());
const activePurchaseRequestRef = useRef<{ sku: string } | null>(null);
@@ -384,6 +414,7 @@ export function StoreKiloPassPurchaseProvider({ children }: { children: ReactNod
if (message) {
captureEvent(KILO_PASS_PURCHASE_FAILED_EVENT);
showDedupedPurchaseError(message);
+ setErrorMessage(message);
}
},
onPurchaseSuccess: purchase => {
@@ -442,6 +473,7 @@ export function StoreKiloPassPurchaseProvider({ children }: { children: ReactNod
// Only user-initiated purchases reach here — recovery and restore
// flows pass notifyCompletion: false.
captureEvent(KILO_PASS_PURCHASE_COMPLETED_EVENT);
+ setErrorMessage(null);
const onCompleted = pendingPurchaseCompletedCallbackRef.current;
pendingPurchaseCompletedCallbackRef.current = null;
onCompleted?.();
@@ -451,6 +483,7 @@ export function StoreKiloPassPurchaseProvider({ children }: { children: ReactNod
},
showError: message => {
showDedupedPurchaseError(message);
+ setErrorMessage(message);
},
}),
[
@@ -472,6 +505,7 @@ export function StoreKiloPassPurchaseProvider({ children }: { children: ReactNod
activePurchaseRequestRef.current = { sku: product.appleProductId };
setIsRequestingPurchase(true);
+ setErrorMessage(null);
captureEvent(KILO_PASS_PURCHASE_STARTED_EVENT);
try {
const requestStarted = await actions.purchase(product, options);
@@ -496,6 +530,7 @@ export function StoreKiloPassPurchaseProvider({ children }: { children: ReactNod
}
setIsRestoringPurchases(true);
+ setErrorMessage(null);
try {
return await actions.restorePurchases();
} finally {
@@ -557,10 +592,14 @@ export function StoreKiloPassPurchaseProvider({ children }: { children: ReactNod
restorePurchases,
isPending: isRequestingPurchase || completeAppStorePurchase.isPending || isRestoringPurchases,
isRestoringPurchases,
+ errorMessage,
+ clearError,
}),
[
appStoreOwnershipPreflight,
+ clearError,
completeAppStorePurchase.isPending,
+ errorMessage,
isRequestingPurchase,
isRestoringPurchases,
restorePurchases,
diff --git a/apps/mobile/src/lib/kiloclaw/access-issue.ts b/apps/mobile/src/lib/kiloclaw/access-issue.ts
new file mode 100644
index 0000000000..0fac84c8e1
--- /dev/null
+++ b/apps/mobile/src/lib/kiloclaw/access-issue.ts
@@ -0,0 +1,13 @@
+import { type AccessRequiredSubcase } from '@/lib/analytics/onboarding-events';
+import { WEB_BASE_URL } from '@/lib/config';
+
+const SUBSCRIBE_SUBCASES: ReadonlySet = new Set([
+ 'trial_expired',
+ 'subscription_canceled',
+ 'subscription_past_due',
+]);
+
+/** Where an access-issue CTA should send the user: billing (/claw) or the generic site. */
+export function resolveAccessIssueUrl(subcase: AccessRequiredSubcase): string {
+ return SUBSCRIBE_SUBCASES.has(subcase) ? `${WEB_BASE_URL}/claw` : WEB_BASE_URL;
+}
diff --git a/apps/mobile/src/lib/onboarding/index.ts b/apps/mobile/src/lib/onboarding/index.ts
index f0bd6b454a..6663fe6f5e 100644
--- a/apps/mobile/src/lib/onboarding/index.ts
+++ b/apps/mobile/src/lib/onboarding/index.ts
@@ -16,7 +16,8 @@ export {
} from './machine';
export {
- isProvisioningTerminal,
+ getProvisioningTerminalReason,
+ type ProvisioningTerminalReason,
shouldAdvanceFromProvisioning,
shouldFireCompletion,
shouldFireOnboardingEntered,
diff --git a/apps/mobile/src/lib/onboarding/machine-provisioning-terminal.test.ts b/apps/mobile/src/lib/onboarding/machine-provisioning-terminal.test.ts
new file mode 100644
index 0000000000..179a3e9c80
--- /dev/null
+++ b/apps/mobile/src/lib/onboarding/machine-provisioning-terminal.test.ts
@@ -0,0 +1,32 @@
+/**
+ * Reducer coverage for the provisioning terminal signals added alongside
+ * `getProvisioningTerminalReason` (query errors, and their reset on retry).
+ * The overall wall-clock timeout is tracked locally in `ProvisioningStep`,
+ * not in the reducer — see `machine.ts`. Split out of `machine.test.ts` to
+ * stay under the file's line budget.
+ */
+import { describe, expect, it } from 'vitest';
+
+import { INITIAL_STATE, type OnboardingEvent, reduce } from './machine';
+
+function run(events: OnboardingEvent[]) {
+ let state = INITIAL_STATE;
+ for (const event of events) {
+ state = reduce(state, event);
+ }
+ return state;
+}
+
+describe('provisioning-query-errored', () => {
+ it('flips queryErrored', () => {
+ const s = reduce(INITIAL_STATE, { type: 'provisioning-query-errored' });
+ expect(s.queryErrored).toBe(true);
+ });
+});
+
+describe('retry-requested clears terminal signals', () => {
+ it('clears queryErrored', () => {
+ const s = run([{ type: 'provisioning-query-errored' }, { type: 'retry-requested' }]);
+ expect(s.queryErrored).toBe(false);
+ });
+});
diff --git a/apps/mobile/src/lib/onboarding/machine.ts b/apps/mobile/src/lib/onboarding/machine.ts
index d81d78bf07..8e74cb8056 100644
--- a/apps/mobile/src/lib/onboarding/machine.ts
+++ b/apps/mobile/src/lib/onboarding/machine.ts
@@ -81,6 +81,13 @@ export type OnboardingState = {
first502AtMs: number | null;
gateway502Expired: boolean;
+ // Terminal signal fed in from outside the 502-grace sub-machine: a hard
+ // query error (status/gateway-ready query failed). The overall wall-clock
+ // provisioning timeout is tracked locally in `ProvisioningStep` (see
+ // `./selectors.ts` `getProvisioningTerminalReason`) since it has no other
+ // producer or consumer.
+ queryErrored: boolean;
+
// Analytics fire-once guards.
onboardingEnteredFired: boolean;
completionReachedFired: boolean;
@@ -106,6 +113,7 @@ export const INITIAL_STATE: OnboardingState = {
execPresetSaved: false,
first502AtMs: null,
gateway502Expired: false,
+ queryErrored: false,
onboardingEnteredFired: false,
completionReachedFired: false,
};
@@ -128,6 +136,7 @@ export type OnboardingEvent =
nowMs: number;
}
| { type: 'gateway-grace-elapsed' }
+ | { type: 'provisioning-query-errored' }
| { type: 'bot-identity-saved' }
| { type: 'exec-preset-saved' }
@@ -245,6 +254,10 @@ export function reduce(state: OnboardingState, event: OnboardingEvent): Onboardi
return { ...state, gateway502Expired: true };
}
+ case 'provisioning-query-errored': {
+ return { ...state, queryErrored: true };
+ }
+
case 'provisioning-complete-acknowledged': {
return { ...state, step: 'done' };
}
@@ -266,6 +279,7 @@ export function reduce(state: OnboardingState, event: OnboardingEvent): Onboardi
completionReachedFired: false,
first502AtMs: null,
gateway502Expired: false,
+ queryErrored: false,
};
}
diff --git a/apps/mobile/src/lib/onboarding/selectors.test.ts b/apps/mobile/src/lib/onboarding/selectors.test.ts
index 45eb65335b..89ba5f70ba 100644
--- a/apps/mobile/src/lib/onboarding/selectors.test.ts
+++ b/apps/mobile/src/lib/onboarding/selectors.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import { type BotIdentity } from './index';
import { INITIAL_STATE, type OnboardingEvent, reduce } from './machine';
import {
+ getProvisioningTerminalReason,
isProvisioningTerminal,
shouldAdvanceFromProvisioning,
shouldFireCompletion,
@@ -219,7 +220,7 @@ describe('isProvisioningTerminal', () => {
},
{ type: 'gateway-grace-elapsed' },
]);
- expect(isProvisioningTerminal(s)).toBe(true);
+ expect(isProvisioningTerminal(s, false)).toBe(true);
});
it('is false during the grace window', () => {
@@ -230,6 +231,42 @@ describe('isProvisioningTerminal', () => {
status: 502,
nowMs: 0,
});
- expect(isProvisioningTerminal(s)).toBe(false);
+ expect(isProvisioningTerminal(s, false)).toBe(false);
+ });
+
+ it('is true once a hard query error is fed in', () => {
+ const s = reduce(INITIAL_STATE, { type: 'provisioning-query-errored' });
+ expect(isProvisioningTerminal(s, false)).toBe(true);
+ expect(getProvisioningTerminalReason(s, false)).toBe('query_error');
+ });
+
+ it('is true once the instance status is a terminal lifecycle state (stopped)', () => {
+ const s = reduce(INITIAL_STATE, { type: 'instance-status-changed', status: 'stopped' });
+ expect(isProvisioningTerminal(s, false)).toBe(true);
+ expect(getProvisioningTerminalReason(s, false)).toBe('instance_stopped');
+ });
+
+ it('is false for a non-terminal instance status', () => {
+ const s = reduce(INITIAL_STATE, { type: 'instance-status-changed', status: 'starting' });
+ expect(isProvisioningTerminal(s, false)).toBe(false);
+ expect(getProvisioningTerminalReason(s, false)).toBeNull();
+ });
+
+ it('is true once the overall provisioning timeout elapses', () => {
+ expect(isProvisioningTerminal(INITIAL_STATE, true)).toBe(true);
+ expect(getProvisioningTerminalReason(INITIAL_STATE, true)).toBe('timeout');
+ });
+
+ it('prioritizes query_error over an instance_stopped status', () => {
+ const s = run([
+ { type: 'instance-status-changed', status: 'stopped' },
+ { type: 'provisioning-query-errored' },
+ ]);
+ expect(getProvisioningTerminalReason(s, false)).toBe('query_error');
+ });
+
+ it('clears after retry-requested so a fresh attempt is not terminal', () => {
+ const s = run([{ type: 'provisioning-query-errored' }, { type: 'retry-requested' }]);
+ expect(isProvisioningTerminal(s, false)).toBe(false);
});
});
diff --git a/apps/mobile/src/lib/onboarding/selectors.ts b/apps/mobile/src/lib/onboarding/selectors.ts
index 28a3a3f528..99c68c0ddc 100644
--- a/apps/mobile/src/lib/onboarding/selectors.ts
+++ b/apps/mobile/src/lib/onboarding/selectors.ts
@@ -89,10 +89,54 @@ export function shouldAdvanceFromProvisioning(state: OnboardingState): boolean {
return state.instanceStatus === 'running' && state.gatewayReady && state.gatewaySettled;
}
+/**
+ * Instance lifecycle statuses that mean provisioning cannot proceed
+ * automatically. Mirrors the web onboarding wizard's
+ * `CLAW_ONBOARDING_ERROR_STATUSES` (`stopped`). Mobile's `InstanceStatus`
+ * type has no `crashed` value today; add it here if the backend introduces
+ * one.
+ */
+const TERMINAL_INSTANCE_STATUSES = new Set(['stopped']);
+
+export type ProvisioningTerminalReason =
+ | 'query_error'
+ | 'instance_stopped'
+ | 'gateway_502'
+ | 'timeout';
+
+/**
+ * Why (if at all) the provisioning step should render its terminal view,
+ * in priority order: a hard query error outranks a stopped instance, which
+ * outranks the 502-grace timer, which outranks the overall wall-clock
+ * timeout. Returns `null` while provisioning is still progressing normally.
+ *
+ * `timedOut` is passed in rather than read from `state` because the overall
+ * wall-clock timeout has no other producer/consumer than `ProvisioningStep`,
+ * which tracks it as local `useState` instead of a machine round-trip.
+ */
+export function getProvisioningTerminalReason(
+ state: OnboardingState,
+ timedOut: boolean
+): ProvisioningTerminalReason | null {
+ if (state.queryErrored) {
+ return 'query_error';
+ }
+ if (state.instanceStatus !== null && TERMINAL_INSTANCE_STATUSES.has(state.instanceStatus)) {
+ return 'instance_stopped';
+ }
+ if (state.gateway502Expired) {
+ return 'gateway_502';
+ }
+ if (timedOut) {
+ return 'timeout';
+ }
+ return null;
+}
+
/**
* Whether the provisioning step should render its terminal "Provisioning failed"
- * view. True iff the 30s 502 grace window has elapsed.
+ * view. True iff `getProvisioningTerminalReason` finds a terminal cause.
*/
-export function isProvisioningTerminal(state: OnboardingState): boolean {
- return state.gateway502Expired;
+export function isProvisioningTerminal(state: OnboardingState, timedOut: boolean): boolean {
+ return getProvisioningTerminalReason(state, timedOut) !== null;
}
diff --git a/apps/mobile/src/lib/route-params.test.ts b/apps/mobile/src/lib/route-params.test.ts
new file mode 100644
index 0000000000..2ee6514ee8
--- /dev/null
+++ b/apps/mobile/src/lib/route-params.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+
+import { parseParam } from './route-params';
+
+describe('parseParam', () => {
+ it('returns null for a missing value', () => {
+ expect(parseParam(undefined)).toBeNull();
+ });
+
+ it('returns null for an array value', () => {
+ expect(parseParam(['a', 'b'])).toBeNull();
+ });
+
+ it('returns null for an empty string', () => {
+ expect(parseParam('')).toBeNull();
+ });
+
+ it('returns the value when no allowlist is given', () => {
+ expect(parseParam('anything')).toBe('anything');
+ });
+
+ it('returns null when the value is not in the allowlist', () => {
+ expect(parseParam('carrot', ['github', 'gitlab'] as const)).toBeNull();
+ });
+
+ it('returns the value when it is in the allowlist', () => {
+ expect(parseParam('gitlab', ['github', 'gitlab'] as const)).toBe('gitlab');
+ });
+});
diff --git a/apps/mobile/src/lib/route-params.ts b/apps/mobile/src/lib/route-params.ts
new file mode 100644
index 0000000000..bca88b4843
--- /dev/null
+++ b/apps/mobile/src/lib/route-params.ts
@@ -0,0 +1,23 @@
+/**
+ * Runtime-validates an Expo Router param. Route generics only describe the
+ * shape TypeScript hopes for — a malformed or hand-built deep link can still
+ * hand a screen `undefined` (missing segment) or a `string[]` (repeated
+ * segment), so every dynamic route param must be checked before it's used
+ * in a query or mutation.
+ *
+ * Returns `null` for a missing/array value, or — when `allowed` is given —
+ * for any value outside that allowlist (narrowing the result to the
+ * allowlist's element type).
+ */
+export function parseParam(
+ value: string | string[] | undefined,
+ allowed?: readonly T[]
+): T | null {
+ if (typeof value !== 'string' || value.length === 0) {
+ return null;
+ }
+ if (allowed && !allowed.includes(value as T)) {
+ return null;
+ }
+ return value as T;
+}
diff --git a/apps/mobile/src/lib/screen-insets.ts b/apps/mobile/src/lib/screen-insets.ts
new file mode 100644
index 0000000000..f16cdc5765
--- /dev/null
+++ b/apps/mobile/src/lib/screen-insets.ts
@@ -0,0 +1,8 @@
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+// Non-tab detail screens (no tab bar clearance needed) — floors bottom inset
+// at 16 so devices without a home indicator still get breathing room.
+export function useDetailScreenBottomPadding() {
+ const { bottom } = useSafeAreaInsets();
+ return Math.max(bottom, 16) + 16;
+}
diff --git a/apps/mobile/src/lib/security-agent.ts b/apps/mobile/src/lib/security-agent.ts
index bd56c6f75e..97473f3c45 100644
--- a/apps/mobile/src/lib/security-agent.ts
+++ b/apps/mobile/src/lib/security-agent.ts
@@ -1,4 +1,3 @@
-import { type OrganizationRole } from '@kilocode/app-shared/organizations';
import { type inferRouterInputs, type inferRouterOutputs, type RootRouter } from '@kilocode/trpc';
import { type Href } from 'expo-router';
@@ -10,7 +9,6 @@ export type SecurityAgentConfigPatch = RouterInputs['securityAgent']['saveConfig
export type SecurityFinding = RouterOutputs['securityAgent']['getFinding'];
export type SecurityAnalysis = RouterOutputs['securityAgent']['getAnalysis'];
export type SecurityCommand = NonNullable;
-export type { OrganizationRole };
export function getSecurityAgentPath(scope: string, suffix = ''): Href {
const path = `/(app)/(tabs)/(3_profile)/security-agent/${scope}`;
diff --git a/apps/mobile/src/lib/security-finding-filter-bridge.ts b/apps/mobile/src/lib/security-finding-filter-bridge.ts
new file mode 100644
index 0000000000..0194795721
--- /dev/null
+++ b/apps/mobile/src/lib/security-finding-filter-bridge.ts
@@ -0,0 +1,29 @@
+import { type SecurityFindingFilters } from '@kilocode/app-shared/security-agent';
+
+// Carries the filter sheet's draft in/out-of-band, same shape as the
+// agent-chat picker bridges in picker-bridge.ts: the caller sets it right
+// before pushing the formSheet route, the route reads it once focused, and
+// clears it on blur so a stale bridge never leaks into the next visit.
+type SecurityFindingFilterRepositoryOption = {
+ fullName: string;
+};
+
+type SecurityFindingFilterBridge = {
+ filters: SecurityFindingFilters;
+ repositories: SecurityFindingFilterRepositoryOption[];
+ onApply: (filters: SecurityFindingFilters) => void;
+};
+
+let bridge: SecurityFindingFilterBridge | null = null;
+
+export function setSecurityFindingFilterBridge(next: SecurityFindingFilterBridge) {
+ bridge = next;
+}
+
+export function getSecurityFindingFilterBridge() {
+ return bridge;
+}
+
+export function clearSecurityFindingFilterBridge() {
+ bridge = null;
+}
diff --git a/apps/mobile/src/lib/sentry-consent.test.ts b/apps/mobile/src/lib/sentry-consent.test.ts
new file mode 100644
index 0000000000..9aeeef3f2f
--- /dev/null
+++ b/apps/mobile/src/lib/sentry-consent.test.ts
@@ -0,0 +1,77 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { reinitSentryForConsent, sentryOptionsForConsent } from './sentry-consent';
+
+const closeMock = vi.hoisted(() => vi.fn());
+
+vi.mock('@sentry/react-native', () => ({ close: closeMock }));
+
+describe('sentryOptionsForConsent', () => {
+ it('disables replay, screenshots, and view-hierarchy when consent is declined', () => {
+ expect(sentryOptionsForConsent(false)).toEqual({
+ replaysSessionSampleRate: 0,
+ replaysOnErrorSampleRate: 0,
+ attachScreenshot: false,
+ attachViewHierarchy: false,
+ });
+ });
+
+ it('enables replay, screenshots, and view-hierarchy when consent is accepted', () => {
+ const options = sentryOptionsForConsent(true);
+
+ expect(options.attachScreenshot).toBe(true);
+ expect(options.attachViewHierarchy).toBe(true);
+ expect(options.replaysSessionSampleRate).toBeGreaterThan(0);
+ expect(options.replaysOnErrorSampleRate).toBeGreaterThan(0);
+ });
+});
+
+describe('reinitSentryForConsent', () => {
+ beforeEach(() => {
+ closeMock.mockReset();
+ });
+
+ it('awaits Sentry.close() before re-initing with the new consent', async () => {
+ const events: string[] = [];
+ closeMock.mockImplementation(() => {
+ events.push('close');
+ });
+ const init = vi.fn((consented: boolean) => {
+ events.push(`init:${consented}`);
+ });
+
+ await reinitSentryForConsent(true, init);
+
+ expect(events).toEqual(['close', 'init:true']);
+ });
+
+ it('serializes overlapping consent transitions', async () => {
+ const events: string[] = [];
+ const firstCloseGate = Promise.withResolvers();
+ closeMock.mockImplementationOnce(async () => {
+ events.push('close');
+ await firstCloseGate.promise;
+ });
+ closeMock.mockImplementation(() => {
+ events.push('close');
+ });
+ const init = vi.fn((consented: boolean) => {
+ events.push(`init:${consented}`);
+ });
+
+ void reinitSentryForConsent(true, init);
+ const done = reinitSentryForConsent(false, init);
+
+ // The second transition must not start (no second close, no init)
+ // while the first close is still pending.
+ await vi.waitFor(() => {
+ expect(closeMock).toHaveBeenCalledTimes(1);
+ });
+ expect(init).not.toHaveBeenCalled();
+
+ firstCloseGate.resolve(null);
+ await done;
+
+ expect(events).toEqual(['close', 'init:true', 'close', 'init:false']);
+ });
+});
diff --git a/apps/mobile/src/lib/sentry-consent.ts b/apps/mobile/src/lib/sentry-consent.ts
new file mode 100644
index 0000000000..3de48fcbbd
--- /dev/null
+++ b/apps/mobile/src/lib/sentry-consent.ts
@@ -0,0 +1,62 @@
+import * as Sentry from '@sentry/react-native';
+
+// Session replay, screenshots, and view-hierarchy capture must not run
+// before the user accepts consent (the consent copy only promises
+// "anonymous performance and crash data" — see consent-card.tsx). This is
+// the pure decision function; src/app/_layout.tsx re-inits Sentry with
+// these options (via reinitSentryForConsent below) whenever the stored
+// consent state changes.
+type SentryConsentOptions = {
+ readonly replaysSessionSampleRate: number;
+ readonly replaysOnErrorSampleRate: number;
+ readonly attachScreenshot: boolean;
+ readonly attachViewHierarchy: boolean;
+};
+
+export function sentryOptionsForConsent(consented: boolean): SentryConsentOptions {
+ if (!consented) {
+ return {
+ replaysSessionSampleRate: 0,
+ replaysOnErrorSampleRate: 0,
+ attachScreenshot: false,
+ attachViewHierarchy: false,
+ };
+ }
+
+ return {
+ replaysSessionSampleRate: 0.1,
+ replaysOnErrorSampleRate: 1,
+ attachScreenshot: true,
+ attachViewHierarchy: true,
+ };
+}
+
+// @sentry/react-native 7.x has no runtime start/stop API for Mobile Replay —
+// the native SDK samples replay once, from the rates passed to Sentry.init,
+// and Sentry.init alone neither closes the previous client nor stops an
+// in-flight native recording. Sentry.close() is the only supported teardown
+// (it awaits closeNativeSdk, which uninstalls the native replay integration),
+// so every consent transition is close-then-init, chained onto `lifecycle`
+// so a fast accept → revoke can't interleave close and init.
+// Each transition catches its own failure, so the chain itself never
+// rejects and can't poison later ones — they re-attempt their own
+// close+init. Failures surface through the caller's `onFailure`.
+let lifecycle: Promise | undefined = undefined;
+
+export async function reinitSentryForConsent(
+ consented: boolean,
+ init: (consented: boolean) => void,
+ onFailure?: () => void
+): Promise {
+ const previous = lifecycle;
+ lifecycle = (async () => {
+ await previous;
+ try {
+ await Sentry.close();
+ init(consented);
+ } catch {
+ onFailure?.();
+ }
+ })();
+ await lifecycle;
+}
diff --git a/apps/mobile/src/lib/session-list-cache.test.ts b/apps/mobile/src/lib/session-list-cache.test.ts
new file mode 100644
index 0000000000..9434db4317
--- /dev/null
+++ b/apps/mobile/src/lib/session-list-cache.test.ts
@@ -0,0 +1,143 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ mapStoredSessions,
+ removeStoredSession,
+ type SessionsListData,
+ type SessionsListPage,
+} from '@/lib/session-list-cache';
+
+function makeSession(
+ overrides: Partial = {}
+): SessionsListPage['cliSessions'][number] {
+ return {
+ session_id: 's1',
+ title: 'Untitled',
+ cloud_agent_session_id: null,
+ parent_session_id: null,
+ organization_id: null,
+ created_on_platform: 'cli',
+ git_url: null,
+ git_branch: null,
+ status: null,
+ status_updated_at: null,
+ created_at: '2026-07-01 00:00:00+00',
+ updated_at: '2026-07-01 00:00:00+00',
+ version: 0,
+ associatedPr: null,
+ ...overrides,
+ };
+}
+
+function makePage(overrides: Partial = {}): SessionsListPage {
+ return { cliSessions: [], nextCursor: null, ...overrides };
+}
+
+function firstPage(data: SessionsListData): SessionsListPage {
+ const page = data.pages[0];
+ if (!page) {
+ throw new Error('expected at least one page');
+ }
+ return page;
+}
+
+describe('mapStoredSessions', () => {
+ it('updates only the matching session and leaves others untouched', () => {
+ const other = makeSession({ session_id: 's2', title: 'Other' });
+ const target = makeSession({ session_id: 's1', title: 'Old title' });
+ const data: SessionsListData = {
+ pages: [makePage({ cliSessions: [target, other] })],
+ pageParams: [undefined],
+ };
+
+ const result = mapStoredSessions(data, 's1', session => ({ ...session, title: 'New title' }));
+
+ const page = firstPage(result);
+ expect(page.cliSessions.find(s => s.session_id === 's1')?.title).toBe('New title');
+ expect(page.cliSessions.find(s => s.session_id === 's2')?.title).toBe('Other');
+ });
+
+ it('passes through unchanged when no session matches', () => {
+ const session = makeSession({ session_id: 's1', title: 'Original' });
+ const data: SessionsListData = {
+ pages: [makePage({ cliSessions: [session] })],
+ pageParams: [undefined],
+ };
+
+ const result = mapStoredSessions(data, 'missing', s => ({ ...s, title: 'Changed' }));
+
+ expect(firstPage(result).cliSessions[0]?.title).toBe('Original');
+ });
+
+ it('passes through unchanged on an empty page list', () => {
+ const data: SessionsListData = { pages: [], pageParams: [] };
+
+ const result = mapStoredSessions(data, 's1', s => ({ ...s, title: 'Changed' }));
+
+ expect(result.pages).toEqual([]);
+ });
+
+ it('preserves the infinite-query page shape (pageParams, nextCursor, page count)', () => {
+ const session = makeSession({ session_id: 's1', title: 'Original' });
+ const data: SessionsListData = {
+ pages: [makePage({ cliSessions: [session], nextCursor: '2026-07-01 00:00:00+00' })],
+ pageParams: [undefined],
+ };
+
+ const result = mapStoredSessions(data, 's1', s => ({ ...s, title: 'Changed' }));
+
+ expect(result.pageParams).toBe(data.pageParams);
+ expect(result.pages).toHaveLength(1);
+ expect(firstPage(result).nextCursor).toBe('2026-07-01 00:00:00+00');
+ });
+});
+
+describe('removeStoredSession', () => {
+ it('removes only the target session, leaving others in place', () => {
+ const target = makeSession({ session_id: 's1' });
+ const other = makeSession({ session_id: 's2' });
+ const data: SessionsListData = {
+ pages: [makePage({ cliSessions: [target, other] })],
+ pageParams: [undefined],
+ };
+
+ const result = removeStoredSession(data, 's1');
+
+ expect(firstPage(result).cliSessions.map(s => s.session_id)).toEqual(['s2']);
+ });
+
+ it('passes through unchanged when no session matches', () => {
+ const session = makeSession({ session_id: 's1' });
+ const data: SessionsListData = {
+ pages: [makePage({ cliSessions: [session] })],
+ pageParams: [undefined],
+ };
+
+ const result = removeStoredSession(data, 'missing');
+
+ expect(firstPage(result).cliSessions).toHaveLength(1);
+ });
+
+ it('passes through unchanged on an empty page list', () => {
+ const data: SessionsListData = { pages: [], pageParams: [] };
+
+ const result = removeStoredSession(data, 's1');
+
+ expect(result.pages).toEqual([]);
+ });
+
+ it('preserves the infinite-query page shape (pageParams, nextCursor, page count)', () => {
+ const target = makeSession({ session_id: 's1' });
+ const other = makeSession({ session_id: 's2' });
+ const data: SessionsListData = {
+ pages: [makePage({ cliSessions: [target, other], nextCursor: 'abc' })],
+ pageParams: [undefined],
+ };
+
+ const result = removeStoredSession(data, 's1');
+
+ expect(result.pageParams).toBe(data.pageParams);
+ expect(result.pages).toHaveLength(1);
+ expect(firstPage(result).nextCursor).toBe('abc');
+ });
+});
diff --git a/apps/mobile/src/lib/session-list-cache.ts b/apps/mobile/src/lib/session-list-cache.ts
new file mode 100644
index 0000000000..10bce3e94f
--- /dev/null
+++ b/apps/mobile/src/lib/session-list-cache.ts
@@ -0,0 +1,33 @@
+import { type inferRouterOutputs, type RootRouter } from '@kilocode/trpc';
+
+type RouterOutputs = inferRouterOutputs;
+export type SessionsListPage = RouterOutputs['cliSessionsV2']['list'];
+export type SessionsListData = { pages: SessionsListPage[]; pageParams: unknown[] };
+
+export function mapStoredSessions(
+ data: SessionsListData,
+ sessionId: string,
+ update: (
+ session: SessionsListPage['cliSessions'][number]
+ ) => SessionsListPage['cliSessions'][number]
+): SessionsListData {
+ return {
+ ...data,
+ pages: data.pages.map(page => ({
+ ...page,
+ cliSessions: page.cliSessions.map(session =>
+ session.session_id === sessionId ? update(session) : session
+ ),
+ })),
+ };
+}
+
+export function removeStoredSession(data: SessionsListData, sessionId: string): SessionsListData {
+ return {
+ ...data,
+ pages: data.pages.map(page => ({
+ ...page,
+ cliSessions: page.cliSessions.filter(session => session.session_id !== sessionId),
+ })),
+ };
+}
diff --git a/apps/mobile/src/lib/utils.ts b/apps/mobile/src/lib/utils.ts
index afafbc4d14..24f7c9decb 100644
--- a/apps/mobile/src/lib/utils.ts
+++ b/apps/mobile/src/lib/utils.ts
@@ -1,4 +1,4 @@
-import { firstNonEmpty, parseTimestamp } from '@kilocode/app-shared/utils';
+import { firstNonEmpty, formatDate, parseTimestamp } from '@kilocode/app-shared/utils';
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
@@ -37,4 +37,28 @@ function timeAgo(date: Date): string {
// eslint-disable-next-line no-empty-function -- intentional no-op
async function asyncNoop() {}
-export { asyncNoop, cn, EMAIL_PATTERN, firstNonEmpty, parseTimestamp, timeAgo };
+/** Builds a new object containing only the given keys of `obj`. */
+function pick(obj: T, keys: readonly K[]): Pick {
+ const result: Partial = {};
+ for (const key of keys) {
+ result[key] = obj[key];
+ }
+ return result as Pick;
+}
+
+/** Uppercases the first letter, e.g. for enum-like values used as labels. */
+function capitalize(value: string): string {
+ return value.charAt(0).toUpperCase() + value.slice(1);
+}
+
+export {
+ asyncNoop,
+ capitalize,
+ cn,
+ EMAIL_PATTERN,
+ firstNonEmpty,
+ formatDate,
+ parseTimestamp,
+ pick,
+ timeAgo,
+};
diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts
index 5fcd48ecf2..c48690fa15 100644
--- a/apps/mobile/vitest.config.ts
+++ b/apps/mobile/vitest.config.ts
@@ -26,6 +26,7 @@ export default defineConfig({
include: [
'src/lib/*.test.ts',
'src/lib/agent-attachments/**/*.test.ts',
+ 'src/lib/auth/**/*.test.ts',
'src/lib/apple-iap/**/*.test.ts',
'src/lib/apple-iap/**/*.test.tsx',
'src/lib/hooks/**/*.test.ts',
diff --git a/packages/app-shared/src/security-agent/dashboard.test.ts b/packages/app-shared/src/security-agent/dashboard.test.ts
index 6be13b5a7a..4691a1fe22 100644
--- a/packages/app-shared/src/security-agent/dashboard.test.ts
+++ b/packages/app-shared/src/security-agent/dashboard.test.ts
@@ -100,7 +100,7 @@ describe('buildSecurityDashboardMetrics', () => {
]);
});
- it('reports 100% SLA compliance instead of dividing by zero when there are no findings', () => {
+ it('reports "Not measured" instead of dividing by zero when there are no SLA-tracked findings', () => {
const data = makeStats({
sla: {
overall: { total: 0, withinSla: 0, overdue: 0 },
@@ -117,8 +117,8 @@ describe('buildSecurityDashboardMetrics', () => {
expect(complianceMetric).toEqual({
id: 'slaCompliance',
label: 'SLA compliance',
- value: '100%',
- detail: 'No assigned deadlines',
+ value: 'Not measured',
+ detail: 'No findings with an SLA deadline yet',
tone: 'neutral',
});
});
diff --git a/packages/app-shared/src/security-agent/dashboard.ts b/packages/app-shared/src/security-agent/dashboard.ts
index 1cb9f1229e..ad84c879e3 100644
--- a/packages/app-shared/src/security-agent/dashboard.ts
+++ b/packages/app-shared/src/security-agent/dashboard.ts
@@ -102,21 +102,22 @@ export function buildSecurityDashboardMetrics(
];
}
- const compliance =
- data.sla.overall.total > 0
- ? Math.round((data.sla.overall.withinSla / data.sla.overall.total) * 100)
- : 100;
+ // With no SLA-tracked findings there's nothing to divide — showing "100%"
+ // reads as "fully compliant" when compliance was never actually measured.
+ const hasSlaData = data.sla.overall.total > 0;
+ const compliance = hasSlaData
+ ? Math.round((data.sla.overall.withinSla / data.sla.overall.total) * 100)
+ : null;
return [
{
id: 'slaCompliance',
label: 'SLA compliance',
- value: `${compliance}%`,
- detail:
- data.sla.overall.total > 0
- ? `${data.sla.overall.withinSla} of ${data.sla.overall.total} within deadline`
- : 'No assigned deadlines',
- tone: complianceTone(compliance),
+ value: compliance === null ? 'Not measured' : `${compliance}%`,
+ detail: hasSlaData
+ ? `${data.sla.overall.withinSla} of ${data.sla.overall.total} within deadline`
+ : 'No findings with an SLA deadline yet',
+ tone: compliance === null ? 'neutral' : complianceTone(compliance),
},
{
id: 'deadlinePassed',
diff --git a/packages/app-shared/src/utils.test.ts b/packages/app-shared/src/utils.test.ts
new file mode 100644
index 0000000000..4f6cbafb29
--- /dev/null
+++ b/packages/app-shared/src/utils.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from 'vitest';
+
+import { parseTimestamp } from './utils';
+
+describe('parseTimestamp', () => {
+ it('parses a date-only string as UTC midnight', () => {
+ expect(parseTimestamp('2026-09-26').toISOString()).toBe('2026-09-26T00:00:00.000Z');
+ });
+
+ it('parses a PostgreSQL timestamp with a short tz offset', () => {
+ expect(parseTimestamp('2026-03-16 15:21:40.957+00').toISOString()).toBe(
+ '2026-03-16T15:21:40.957Z'
+ );
+ });
+});
diff --git a/packages/app-shared/src/utils.ts b/packages/app-shared/src/utils.ts
index bc9aaa318e..74421f5990 100644
--- a/packages/app-shared/src/utils.ts
+++ b/packages/app-shared/src/utils.ts
@@ -44,3 +44,15 @@ export function formatCents(amount: number, currency: string = 'USD') {
currency: currency.toUpperCase(),
}).format(amount / 100);
}
+
+/**
+ * Canonical app-wide date format (e.g. "7/11/2026" for en-US).
+ *
+ * Follows the runtime's default locale (device locale on mobile, browser
+ * locale on web) rather than a pinned locale, so it reads naturally for
+ * every user. Pass a `Date`, not a raw backend string — parse backend
+ * timestamps with `parseTimestamp()` first.
+ */
+export function formatDate(date: Date): string {
+ return date.toLocaleDateString();
+}
diff --git a/packages/kilo-chat-hooks/src/use-messages.ts b/packages/kilo-chat-hooks/src/use-messages.ts
index 98e5189fa7..2921baa003 100644
--- a/packages/kilo-chat-hooks/src/use-messages.ts
+++ b/packages/kilo-chat-hooks/src/use-messages.ts
@@ -11,6 +11,7 @@ import type {
MessageUpdatedEvent,
MessageDeletedEvent,
MessageDeliveryFailedEvent,
+ MessageRedeliveredEvent,
ActionDeliveryFailedEvent,
ReactionAddedEvent,
ReactionRemovedEvent,
@@ -725,6 +726,42 @@ export function useEditMessage(client: KiloChatClient, conversationId: string |
});
}
+/**
+ * Retries bot delivery of an existing delivery-failed message. The server
+ * re-attempts delivery of the same message row (no new message) and pushes
+ * `message.redelivered` (clears `deliveryFailed`) right before the attempt,
+ * then `message.delivery_failed` again if the retry fails.
+ *
+ * Not optimistic: an `onMutate` clear would race those events (a rejected
+ * redelivery is indistinguishable from a committed one on an ambiguous HTTP
+ * error), and a reconciling invalidate/refetch can capture a stale
+ * pre-failure snapshot. Instead the clear is applied on HTTP SUCCESS, once the
+ * server has confirmed the redelivery — a targeted single-message write, not a
+ * refetch. This also covers a dropped `message.redelivered` event (the push is
+ * best-effort), which would otherwise strand the flag until an unrelated
+ * refetch. A subsequent, strictly-later `message.delivery_failed` event still
+ * restores failure if the retry ultimately fails.
+ */
+export function useRedeliverMessage(client: KiloChatClient, conversationId: string | null) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ messageId }: { messageId: string }) =>
+ client.redeliverMessage(conversationId ?? '', messageId),
+ onSuccess: (_data, variables) => {
+ if (!conversationId) return;
+ const queryKey = messagesKey(conversationId);
+ queryClient.setQueryData(queryKey, old =>
+ old
+ ? updateMessageInPages(old, variables.messageId, msg => ({
+ ...msg,
+ deliveryFailed: false,
+ }))
+ : old
+ );
+ },
+ });
+}
+
export function useDeleteMessage(client: KiloChatClient, conversationId: string | null) {
const queryClient = useQueryClient();
return useMutation({
@@ -1015,6 +1052,14 @@ export function useMessageCacheUpdater(
onMessageDeliveryFailed?.();
};
+ const onRedelivered = (ctx: string, e: MessageRedeliveredEvent) => {
+ if (ctx !== expectedContext) return;
+ queryClient.setQueryData(queryKey, old => {
+ if (!old) return old;
+ return updateMessageInPages(old, e.messageId, msg => ({ ...msg, deliveryFailed: false }));
+ });
+ };
+
const onActionDeliveryFailed = (ctx: string, e: ActionDeliveryFailedEvent) => {
if (ctx !== expectedContext) return;
queryClient.setQueryData(queryKey, old => {
@@ -1052,6 +1097,7 @@ export function useMessageCacheUpdater(
client.onMessageUpdated(onUpdated),
client.onMessageDeleted(onDeleted),
client.onMessageDeliveryFailed(onDeliveryFailed),
+ client.onMessageRedelivered(onRedelivered),
client.onActionDeliveryFailed(onActionDeliveryFailed),
client.onReactionAdded(onReactionAdded),
client.onReactionRemoved(onReactionRemoved),
diff --git a/packages/kilo-chat/src/client.ts b/packages/kilo-chat/src/client.ts
index e3ac4a8420..4a8e9c9738 100644
--- a/packages/kilo-chat/src/client.ts
+++ b/packages/kilo-chat/src/client.ts
@@ -47,6 +47,7 @@ import type {
MessageUpdatedEvent,
MessageDeletedEvent,
MessageDeliveryFailedEvent,
+ MessageRedeliveredEvent,
ActionDeliveryFailedEvent,
TypingEvent,
ReactionAddedEvent,
@@ -234,6 +235,13 @@ export class KiloChatClient {
});
}
+ async redeliverMessage(conversationId: string, messageId: string): Promise<{ ok: true }> {
+ return this.httpRequest(`/v1/conversations/${conversationId}/messages/${messageId}/redeliver`, {
+ method: 'POST',
+ schema: okResponseSchema,
+ });
+ }
+
async executeAction(
conversationId: string,
messageId: string,
@@ -366,6 +374,10 @@ export class KiloChatClient {
return this.on('message.delivery_failed', handler);
}
+ onMessageRedelivered(handler: (ctx: string, e: MessageRedeliveredEvent) => void): () => void {
+ return this.on('message.redelivered', handler);
+ }
+
onActionDeliveryFailed(handler: (ctx: string, e: ActionDeliveryFailedEvent) => void): () => void {
return this.on('action.delivery_failed', handler);
}
diff --git a/packages/kilo-chat/src/events.ts b/packages/kilo-chat/src/events.ts
index 130b12ba0b..15633a3160 100644
--- a/packages/kilo-chat/src/events.ts
+++ b/packages/kilo-chat/src/events.ts
@@ -39,6 +39,10 @@ export const messageDeliveryFailedEventSchema = z.object({
messageId: ulidSchema,
});
+export const messageRedeliveredEventSchema = z.object({
+ messageId: ulidSchema,
+});
+
export const typingEventSchema = z.object({
memberId: nonEmptyStringSchema,
});
@@ -124,6 +128,7 @@ export const kiloChatEventSchema = z.discriminatedUnion('event', [
event: z.literal('message.delivery_failed'),
payload: messageDeliveryFailedEventSchema,
}),
+ z.object({ event: z.literal('message.redelivered'), payload: messageRedeliveredEventSchema }),
z.object({ event: z.literal('typing'), payload: typingEventSchema }),
z.object({ event: z.literal('typing.stop'), payload: typingEventSchema }),
z.object({ event: z.literal('reaction.added'), payload: reactionAddedEventSchema }),
@@ -160,6 +165,7 @@ const payloadSchemaRegistry: { [K in KiloChatEventName]: z.ZodType;
export type MessageUpdatedEvent = z.infer;
export type MessageDeletedEvent = z.infer;
export type MessageDeliveryFailedEvent = z.infer;
+export type MessageRedeliveredEvent = z.infer;
export type TypingEvent = z.infer;
export type ReactionAddedEvent = z.infer;
export type ReactionRemovedEvent = z.infer;
diff --git a/services/kilo-chat/src/__tests__/conversation-do.test.ts b/services/kilo-chat/src/__tests__/conversation-do.test.ts
index cbbe507ccf..4229af3cad 100644
--- a/services/kilo-chat/src/__tests__/conversation-do.test.ts
+++ b/services/kilo-chat/src/__tests__/conversation-do.test.ts
@@ -1130,4 +1130,128 @@ describe('ConversationDO', () => {
const result = await stub.destroyAndReturnMembers();
expect(result).toBeNull();
});
+
+ it('redeliverMessage - clears delivery_failed for a failed message', async () => {
+ const stub = getStub('conv-redeliver-1');
+ await stub.initialize({ ...BASE_PARAMS, id: 'conv-redeliver-1' });
+ const created = await stub.createMessage({
+ senderId: 'user-alice',
+ content: [{ type: 'text', text: 'Retry me' }],
+ });
+ expect(created.ok).toBe(true);
+ if (!created.ok) return;
+
+ await expect(stub.notifyDeliveryFailed(created.messageId)).resolves.toEqual({
+ ok: true,
+ changed: true,
+ });
+
+ const result = await stub.redeliverMessage({
+ messageId: created.messageId,
+ senderId: 'user-alice',
+ });
+ expect(result).toEqual({
+ ok: true,
+ redelivered: true,
+ });
+
+ const { messages } = await stub.listMessages({ limit: 10 });
+ const msg = messages.find(m => m.id === created.messageId);
+ expect(msg!.deliveryFailed).toBe(false);
+ });
+
+ it('redeliverMessage - no-op for a message that has not failed', async () => {
+ const stub = getStub('conv-redeliver-2');
+ await stub.initialize({ ...BASE_PARAMS, id: 'conv-redeliver-2' });
+ const created = await stub.createMessage({
+ senderId: 'user-alice',
+ content: [{ type: 'text', text: 'Delivered fine' }],
+ });
+ expect(created.ok).toBe(true);
+ if (!created.ok) return;
+
+ const result = await stub.redeliverMessage({
+ messageId: created.messageId,
+ senderId: 'user-alice',
+ });
+ expect(result.ok).toBe(true);
+ if (result.ok) expect(result.redelivered).toBe(false);
+ });
+
+ it('redeliverMessage - rejects a caller who is not the sender', async () => {
+ const stub = getStub('conv-redeliver-3');
+ await stub.initialize({
+ ...BASE_PARAMS,
+ id: 'conv-redeliver-3',
+ members: [...BASE_PARAMS.members, { id: 'user-bob', kind: 'user' as const }],
+ });
+ const created = await stub.createMessage({
+ senderId: 'user-alice',
+ content: [{ type: 'text', text: 'Not yours' }],
+ });
+ expect(created.ok).toBe(true);
+ if (!created.ok) return;
+ await stub.notifyDeliveryFailed(created.messageId);
+
+ const result = await stub.redeliverMessage({
+ messageId: created.messageId,
+ senderId: 'user-bob',
+ });
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.code).toBe('forbidden');
+ });
+
+ it('redeliverMessage - conflict when no bot remains to redeliver to', async () => {
+ const stub = getStub('conv-redeliver-5');
+ await stub.initialize({
+ ...BASE_PARAMS,
+ id: 'conv-redeliver-5',
+ members: [{ id: 'user-alice', kind: 'user' as const }],
+ });
+ const created = await stub.createMessage({
+ senderId: 'user-alice',
+ content: [{ type: 'text', text: 'Nobody home' }],
+ });
+ expect(created.ok).toBe(true);
+ if (!created.ok) return;
+ await stub.notifyDeliveryFailed(created.messageId);
+
+ const result = await stub.redeliverMessage({
+ messageId: created.messageId,
+ senderId: 'user-alice',
+ });
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.code).toBe('conflict');
+
+ // The flag must survive a rejected redelivery.
+ const { messages } = await stub.listMessages({ limit: 10 });
+ expect(messages.find(m => m.id === created.messageId)!.deliveryFailed).toBe(true);
+ });
+
+ it('redeliverMessage - not_found for missing or deleted messages', async () => {
+ const stub = getStub('conv-redeliver-4');
+ await stub.initialize({ ...BASE_PARAMS, id: 'conv-redeliver-4' });
+
+ const missing = await stub.redeliverMessage({
+ messageId: '01ARZ3NDEKTSV4RRFFQ69G5FAV',
+ senderId: 'user-alice',
+ });
+ expect(missing.ok).toBe(false);
+ if (!missing.ok) expect(missing.code).toBe('not_found');
+
+ const created = await stub.createMessage({
+ senderId: 'user-alice',
+ content: [{ type: 'text', text: 'Soon deleted' }],
+ });
+ expect(created.ok).toBe(true);
+ if (!created.ok) return;
+ await stub.deleteMessage({ messageId: created.messageId, senderId: 'user-alice' });
+
+ const deleted = await stub.redeliverMessage({
+ messageId: created.messageId,
+ senderId: 'user-alice',
+ });
+ expect(deleted.ok).toBe(false);
+ if (!deleted.ok) expect(deleted.code).toBe('not_found');
+ });
});
diff --git a/services/kilo-chat/src/__tests__/helpers.ts b/services/kilo-chat/src/__tests__/helpers.ts
index 3b830b99fa..e471310109 100644
--- a/services/kilo-chat/src/__tests__/helpers.ts
+++ b/services/kilo-chat/src/__tests__/helpers.ts
@@ -11,6 +11,7 @@ import {
handleEditMessage,
handleExecuteAction,
handleListMessages,
+ handleRedeliverMessage,
handleRemoveReaction,
handleSetTyping,
handleStopTyping,
@@ -67,6 +68,10 @@ export function makeApp(callerId: string, callerKind: 'user' | 'bot') {
'/v1/conversations/:conversationId/messages/:messageId/execute-action',
handleExecuteAction
);
+ app.post(
+ '/v1/conversations/:conversationId/messages/:messageId/redeliver',
+ handleRedeliverMessage
+ );
app.post('/v1/messages/:messageId/reactions', handleAddReaction);
app.delete('/v1/messages/:messageId/reactions', handleRemoveReaction);
diff --git a/services/kilo-chat/src/__tests__/messages-routes.test.ts b/services/kilo-chat/src/__tests__/messages-routes.test.ts
index f00ce1c9be..1a47713e61 100644
--- a/services/kilo-chat/src/__tests__/messages-routes.test.ts
+++ b/services/kilo-chat/src/__tests__/messages-routes.test.ts
@@ -1534,6 +1534,58 @@ describe('recipient conversation read state after message delivery', () => {
});
});
+describe('POST /v1/conversations/:conversationId/messages/:messageId/redeliver', () => {
+ it('clears deliveryFailed and re-enqueues the bot webhook for the same message', async () => {
+ await recordingKiloclaw.__clearWebhookCalls();
+ const { conversationId, userId, userApp } = await createConversation('redeliver-1');
+ const convStub = getConvStub(conversationId);
+
+ const createRes = await userApp.request(
+ '/v1/messages',
+ {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ conversationId,
+ content: [{ type: 'text', text: 'retry payload' }],
+ }),
+ },
+ env
+ );
+ expect(createRes.status).toBe(201);
+ const { messageId } = await createRes.json<{ messageId: string }>();
+
+ // Wait for the original delivery, then clear the buffer so the
+ // redelivered webhook is distinguishable from the initial one.
+ await waitForWebhookCalls(calls => calls.some(call => call.messageId === messageId));
+ await recordingKiloclaw.__clearWebhookCalls();
+
+ await unwrap(convStub.notifyDeliveryFailed(messageId));
+
+ const res = await userApp.request(
+ `/v1/conversations/${conversationId}/messages/${messageId}/redeliver`,
+ { method: 'POST' },
+ env
+ );
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toEqual({ ok: true });
+
+ // The existing row is intact with the flag cleared — no new message.
+ const list = await convStub.listMessages({ limit: 10 });
+ const messagesById = list.messages.filter(m => m.id === messageId);
+ expect(messagesById).toHaveLength(1);
+ expect(messagesById[0]!.deliveryFailed).toBe(false);
+
+ // The same message was re-delivered to the bot.
+ const calls = await waitForWebhookCalls(cs => cs.some(call => call.messageId === messageId));
+ expect(calls.find(call => call.messageId === messageId)).toMatchObject({
+ messageId,
+ from: userId,
+ text: 'retry payload',
+ });
+ });
+});
+
describe('POST /v1/conversations/:conversationId/messages/:messageId/execute-action', () => {
it('returns the canonical resolved action content', async () => {
const { conversationId, userApp, botId } = await createConversation('execute-action-result');
diff --git a/services/kilo-chat/src/do/conversation-do.ts b/services/kilo-chat/src/do/conversation-do.ts
index df04b0aec5..8a0d549ef3 100644
--- a/services/kilo-chat/src/do/conversation-do.ts
+++ b/services/kilo-chat/src/do/conversation-do.ts
@@ -10,6 +10,7 @@ import {
import { DurableObject } from 'cloudflare:workers';
import { z } from 'zod';
import { logger } from '../util/logger';
+import { contentBlocksToText } from '../util/content';
import { headObject } from '../util/presigner';
import {
deliverToBot,
@@ -52,6 +53,7 @@ import {
botMessageNotificationTextLength,
sendConversationMessagePush,
} from '../services/push-notifications';
+import { pushEventToHumanMembers } from '../services/event-push';
import migrations from '../../drizzle/conversation/migrations';
import { monotonicFactory } from 'ulid';
@@ -255,6 +257,15 @@ export type NotifyDeliveryFailedResult =
| { ok: true; changed: boolean }
| { ok: false; code: 'not_found'; error: string };
+export type RedeliverMessageParams = {
+ messageId: string;
+ senderId: string;
+};
+
+export type RedeliverMessageResult =
+ | { ok: true; redelivered: boolean }
+ | { ok: false; code: 'not_found' | 'forbidden' | 'conflict'; error: string };
+
export type InitAttachmentParams = {
uploaderId: string;
mimeType: string;
@@ -444,6 +455,129 @@ export class ConversationDO extends DurableObject {
return { ok: true, changed: true };
}
+ /**
+ * Re-attempts bot delivery of an existing delivery-failed message. Clears
+ * the `delivery_failed` flag, publishes `message.redelivered` to human
+ * members, then re-enqueues the webhook for the stored content — no new
+ * message row, no attachment re-linking. If the retry fails permanently
+ * again, the normal deliverToBot failure path flips the flag back and
+ * pushes `message.delivery_failed`.
+ */
+ async redeliverMessage(params: RedeliverMessageParams): Promise {
+ const row = this.db.select().from(messages).where(eq(messages.id, params.messageId)).get();
+ if (!row || row.deleted === 1) {
+ return { ok: false, code: 'not_found', error: 'Message not found' };
+ }
+
+ if (params.senderId !== row.sender_id) {
+ return {
+ ok: false,
+ code: 'forbidden',
+ error: `Sender ${params.senderId} is not the owner of message ${params.messageId}`,
+ };
+ }
+
+ const activeMembers = this.getActiveMemberRows();
+ if (!activeMembers.some(member => member.id === params.senderId)) {
+ return {
+ ok: false,
+ code: 'forbidden',
+ error: `Sender ${params.senderId} is not a member of this conversation`,
+ };
+ }
+ const memberContext = this.getMemberContextFromRows(activeMembers);
+
+ // Already delivered (or a concurrent redeliver won) — idempotent no-op so
+ // a double-tap cannot double-deliver to the bot.
+ if (row.delivery_failed !== 1) {
+ return { ok: true, redelivered: false };
+ }
+
+ // delivery_failed is one message-level bit — it doesn't record which
+ // recipient failed. Redelivery is only well-defined with exactly one
+ // eligible bot (the shape every kilo-chat conversation has; see
+ // getMemberContextFromRows). Zero bots must not clear the flag and
+ // report success; multiple would double-deliver to bots that already
+ // accepted the original.
+ const bots = activeMembers.filter(member => member.kind === 'bot');
+ if (bots.length !== 1) {
+ return {
+ ok: false,
+ code: 'conflict',
+ error:
+ bots.length === 0
+ ? 'No active bot to redeliver to'
+ : 'Redelivery is ambiguous with multiple bots',
+ };
+ }
+ const bot = bots[0];
+
+ // Clear synchronously so a double-tap (flag now 0) short-circuits at the
+ // check above instead of double-delivering. If the retry fails again,
+ // deliverToBot -> notifyDeliveryFailed re-sets the flag.
+ this.db.update(messages).set({ delivery_failed: 0 }).where(eq(messages.id, row.id)).run();
+
+ const conversationId = this.getConversationId();
+ const content = parseStoredContent(row.content, row.id);
+
+ // Mirror the reply context the original delivery carried (see
+ // postCommitFanOut in services/messages.ts).
+ let inReplyToBody: string | undefined;
+ let inReplyToSender: string | undefined;
+ if (row.in_reply_to_message_id) {
+ const parent = this.db
+ .select()
+ .from(messages)
+ .where(eq(messages.id, row.in_reply_to_message_id))
+ .get();
+ if (parent && parent.deleted !== 1) {
+ inReplyToBody = contentBlocksToText(parseStoredContent(parent.content, parent.id));
+ inReplyToSender = parent.sender_id;
+ }
+ }
+
+ const sentAt = new Date().toISOString();
+ const webhookMessage: WebhookMessage = {
+ targetBotId: bot.id,
+ conversationId,
+ messageId: row.id,
+ from: row.sender_id,
+ content,
+ sentAt,
+ ...(row.in_reply_to_message_id !== null && {
+ inReplyToMessageId: row.in_reply_to_message_id,
+ }),
+ ...(inReplyToBody !== undefined && { inReplyToBody }),
+ ...(inReplyToSender !== undefined && { inReplyToSender }),
+ };
+
+ // Reserve the webhook-chain slot synchronously — no external await runs
+ // between the check above and this assignment — so a concurrent DO request
+ // can't slot a later message ahead of this redelivery (or run deliverToBot
+ // out of order). The redelivered event is pushed INSIDE the task, strictly
+ // before deliverToBot, so a failure re-flag (deliverToBot ->
+ // notifyDeliveryFailed) always lands after it and is never clobbered by a
+ // delayed clear.
+ this.webhookChain = this.webhookChain
+ .catch(() => {})
+ .then(async () => {
+ if (memberContext.sandboxId) {
+ await pushEventToHumanMembers(
+ this.env,
+ conversationId,
+ memberContext.sandboxId,
+ memberContext.humanMemberIds,
+ 'message.redelivered',
+ { messageId: row.id }
+ );
+ }
+ await deliverToBot(this.env, webhookMessage, memberContext);
+ });
+ this.ctx.waitUntil(this.webhookChain);
+
+ return { ok: true, redelivered: true };
+ }
+
revertActionResolution(params: {
messageId: string;
groupId: string;
diff --git a/services/kilo-chat/src/index.ts b/services/kilo-chat/src/index.ts
index 2d0ebe8f5b..5094514cc7 100644
--- a/services/kilo-chat/src/index.ts
+++ b/services/kilo-chat/src/index.ts
@@ -22,6 +22,7 @@ import {
handleEditMessage,
handleExecuteAction,
handleListMessages,
+ handleRedeliverMessage,
handleRemoveReaction,
handleSetTyping,
handleStopTyping,
@@ -90,6 +91,7 @@ app.post(
'/v1/conversations/:conversationId/messages/:messageId/execute-action',
handleExecuteAction
);
+app.post('/v1/conversations/:conversationId/messages/:messageId/redeliver', handleRedeliverMessage);
// Reactions
app.post('/v1/messages/:messageId/reactions', handleAddReaction);
diff --git a/services/kilo-chat/src/routes/handler.ts b/services/kilo-chat/src/routes/handler.ts
index 371ed6662b..9151fe2e31 100644
--- a/services/kilo-chat/src/routes/handler.ts
+++ b/services/kilo-chat/src/routes/handler.ts
@@ -251,6 +251,29 @@ export async function handleExecuteAction(c: HonoCtx) {
return c.json(result satisfies ExecuteActionResponse);
}
+// ─── redeliverMessage ───────────────────────────────────────────────────────
+
+export async function handleRedeliverMessage(c: HonoCtx) {
+ const convId = parseConversationId(c);
+ if (!convId.ok) return convId.response;
+ const msgId = parseMessageId(c);
+ if (!msgId.ok) return msgId.response;
+
+ const callerId = c.get('callerId');
+ const convStub = c.env.CONVERSATION_DO.get(c.env.CONVERSATION_DO.idFromName(convId.data));
+ const result = await convStub.redeliverMessage({ messageId: msgId.data, senderId: callerId });
+ if (!result.ok) {
+ if (result.code === 'forbidden') return c.json({ error: result.error }, 403);
+ if (result.code === 'conflict') return c.json({ error: result.error }, 409);
+ return c.json({ error: result.error }, 404);
+ }
+
+ // The DO publishes message.redelivered itself, ordered before the delivery
+ // attempt is enqueued, so a fresh delivery failure can never be clobbered
+ // by a delayed clear event.
+ return c.json({ ok: true } satisfies OkResponse);
+}
+
// ─── messageDeliveryFailed (bot-reported) ───────────────────────────────────
export async function handleMessageDeliveryFailed(c: HonoCtx) {
diff --git a/services/kilo-chat/src/services/messages.ts b/services/kilo-chat/src/services/messages.ts
index 2100b8fd47..2c12346ada 100644
--- a/services/kilo-chat/src/services/messages.ts
+++ b/services/kilo-chat/src/services/messages.ts
@@ -17,6 +17,7 @@ import {
} from '@kilocode/kilo-chat';
import { formatError, withDORetry } from '@kilocode/worker-utils';
import { logger } from '../util/logger';
+import { contentBlocksToText } from '../util/content';
import {
extractConversationContext,
pushEventToHumanMembers,
@@ -167,13 +168,7 @@ export async function postCommitFanOut(
if (replyParent) {
const parent = await replyParent;
if (parent && !parent.deleted) {
- inReplyToBody = parent.content
- .filter(
- (b): b is { type: 'text'; text: string } =>
- b.type === 'text' && typeof b.text === 'string'
- )
- .map(b => b.text)
- .join('');
+ inReplyToBody = contentBlocksToText(parent.content);
inReplyToSender = parent.senderId;
}
}
@@ -207,14 +202,7 @@ export async function postCommitFanOut(
// pure computation; the write lives inside the combined fan-out below.
const computeAutoTitle = (): string | null => {
if (info.title !== null) return null;
- const text = content
- .filter(
- (b): b is { type: 'text'; text: string } => b.type === 'text' && typeof b.text === 'string'
- )
- .map(b => b.text)
- .join(' ')
- .replace(/\n/g, ' ')
- .trim();
+ const text = contentBlocksToText(content, ' ').replace(/\n/g, ' ').trim();
if (text.length === 0) return null;
return truncateByGrapheme(text, 80);
};
diff --git a/services/kilo-chat/src/util/content.ts b/services/kilo-chat/src/util/content.ts
index 060e75726a..b72eaca02b 100644
--- a/services/kilo-chat/src/util/content.ts
+++ b/services/kilo-chat/src/util/content.ts
@@ -1,12 +1,12 @@
import type { ContentBlock } from '@kilocode/kilo-chat';
/** Concatenates text content blocks into a single string. Skips non-text blocks. */
-export function contentBlocksToText(content: ContentBlock[]): string {
+export function contentBlocksToText(content: ContentBlock[], separator = ''): string {
return content
.filter(
(b): b is { type: 'text'; text: string } =>
b.type === 'text' && typeof (b as { text?: unknown }).text === 'string'
)
.map(b => b.text)
- .join('');
+ .join(separator);
}
diff --git a/services/kiloclaw/plugins/kilo-chat/src/synced/events.ts b/services/kiloclaw/plugins/kilo-chat/src/synced/events.ts
index 130b12ba0b..15633a3160 100644
--- a/services/kiloclaw/plugins/kilo-chat/src/synced/events.ts
+++ b/services/kiloclaw/plugins/kilo-chat/src/synced/events.ts
@@ -39,6 +39,10 @@ export const messageDeliveryFailedEventSchema = z.object({
messageId: ulidSchema,
});
+export const messageRedeliveredEventSchema = z.object({
+ messageId: ulidSchema,
+});
+
export const typingEventSchema = z.object({
memberId: nonEmptyStringSchema,
});
@@ -124,6 +128,7 @@ export const kiloChatEventSchema = z.discriminatedUnion('event', [
event: z.literal('message.delivery_failed'),
payload: messageDeliveryFailedEventSchema,
}),
+ z.object({ event: z.literal('message.redelivered'), payload: messageRedeliveredEventSchema }),
z.object({ event: z.literal('typing'), payload: typingEventSchema }),
z.object({ event: z.literal('typing.stop'), payload: typingEventSchema }),
z.object({ event: z.literal('reaction.added'), payload: reactionAddedEventSchema }),
@@ -160,6 +165,7 @@ const payloadSchemaRegistry: { [K in KiloChatEventName]: z.ZodType