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} + /> + ); 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() { - - - ))} + ); + })} )} diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/exec-policy.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/exec-policy.tsx index 1d08436a9b..8fb25d60a6 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/exec-policy.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/exec-policy.tsx @@ -1,15 +1,18 @@ import { type LucideIcon, ShieldCheck, Zap } from 'lucide-react-native'; -import { Pressable, ScrollView, View } from 'react-native'; +import { ActivityIndicator, Pressable, ScrollView, View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; import { useLocalSearchParams } from 'expo-router'; +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 { instanceOrgId, useInstanceContext } from '@/lib/hooks/use-instance-context'; import { useKiloClawMutations, useKiloClawStatus } from '@/lib/hooks/use-kiloclaw-queries'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { type ExecPreset, execPresetToConfig } from '@/lib/onboarding'; +import { useDetailScreenBottomPadding } from '@/lib/screen-insets'; import { cn } from '@/lib/utils'; type PolicyOption = { @@ -25,14 +28,14 @@ const POLICY_OPTIONS: PolicyOption[] = [ id: 'always-ask', icon: ShieldCheck, iconColor: '#10b981', - label: 'Always Ask', + label: 'Always ask', description: 'Confirm every command before execution. Most secure.', }, { id: 'never-ask', icon: Zap, iconColor: '#f59e0b', - label: 'Never Ask', + label: 'Never ask', description: 'Execute commands without confirmation. Faster but less safe.', }, ]; @@ -52,16 +55,23 @@ function resolvePreset( export default function ExecPolicyScreen() { const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>(); - const { organizationId } = useInstanceContext(instanceId); + const instanceContext = useInstanceContext(instanceId); + const organizationId = instanceOrgId(instanceContext); const statusQuery = useKiloClawStatus(organizationId); const mutations = useKiloClawMutations(organizationId); + const paddingBottom = useDetailScreenBottomPadding(); + const colors = useThemeColors(); const currentPreset = resolvePreset(statusQuery.data?.execSecurity, statusQuery.data?.execAsk); + if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { + return ; + } + if (statusQuery.isPending) { return ( - + @@ -77,7 +87,7 @@ export default function ExecPolicyScreen() { if (statusQuery.isError) { return ( - + - - + + {POLICY_OPTIONS.map(option => { const Icon = option.icon; @@ -112,6 +134,7 @@ export default function ExecPolicyScreen() { pendingPreset !== undefined ? pendingPreset === option.id : currentPreset === option.id; + const isRowPending = isPending && pendingPreset === option.id; return ( { handleSelect(option); }} @@ -128,14 +154,18 @@ export default function ExecPolicyScreen() { {option.label} - + {isRowPending ? ( + + ) : ( + + )} {option.description} @@ -143,14 +173,6 @@ export default function ExecPolicyScreen() { ); })} - - {mutations.patchExecPreset.isPending && ( - - - Saving... - - - )} diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/google.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/google.tsx index 66fbb61da6..9347d44a5d 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/google.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/google.tsx @@ -1,37 +1,49 @@ import * as Clipboard from 'expo-clipboard'; -import { Unplug } from 'lucide-react-native'; +import { RefreshCw, Unplug } from 'lucide-react-native'; import { useState } from 'react'; import { Alert, ScrollView, View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; import { useLocalSearchParams } from 'expo-router'; import { GmailIcon, GoogleIcon } 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 { captureEvent, INSTANCE_ACTION_EVENT } from '@/lib/analytics/posthog'; +import { instanceOrgId, useInstanceContext } from '@/lib/hooks/use-instance-context'; import { useKiloClawGoogleSetup, useKiloClawMutations, useKiloClawStatus, } from '@/lib/hooks/use-kiloclaw-queries'; +import { useDetailScreenBottomPadding } from '@/lib/screen-insets'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { cn } from '@/lib/utils'; export default function GoogleScreen() { const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>(); - const { organizationId } = useInstanceContext(instanceId); + const instanceContext = useInstanceContext(instanceId); + const organizationId = instanceOrgId(instanceContext); const statusQuery = useKiloClawStatus(organizationId); const mutations = useKiloClawMutations(organizationId); + const paddingBottom = useDetailScreenBottomPadding(); + const colors = useThemeColors(); const [copied, setCopied] = useState(false); + const [showRedeployPrompt, setShowRedeployPrompt] = useState(false); const isConnected = statusQuery.data?.googleConnected ?? false; const gmailEnabled = statusQuery.data?.gmailNotificationsEnabled ?? false; const setupQuery = useKiloClawGoogleSetup(organizationId, !statusQuery.isPending && !isConnected); + if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { + return ; + } + if (statusQuery.isPending) { return ( @@ -87,17 +99,42 @@ export default function GoogleScreen() { text: 'Disconnect', style: 'destructive', onPress: () => { - mutations.disconnectGoogle.mutate(undefined); + mutations.disconnectGoogle.mutate(undefined, { + onSuccess: () => { + setShowRedeployPrompt(true); + }, + }); }, }, ] ); } + function handleRedeploy() { + Alert.alert('Redeploy instance', 'Are you sure you want to redeploy this instance?', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Redeploy', + onPress: () => { + captureEvent(INSTANCE_ACTION_EVENT, { surface: 'claw', action: 'redeploy' }); + mutations.restartMachine.mutate(undefined, { + onSuccess: () => { + setShowRedeployPrompt(false); + }, + }); + }, + }, + ]); + } + return ( - + {/* Connection status card */} @@ -107,13 +144,13 @@ export default function GoogleScreen() { {isConnected ? 'Connected' : 'Not connected'} @@ -124,13 +161,48 @@ export default function GoogleScreen() { {!isConnected && ( + {showRedeployPrompt && ( + + + Google account disconnected. Redeploy your instance to apply the change. + + + + )} - Setup Command + Setup command + + + Run this command in a terminal with Docker installed on your own computer to connect + your Google account. {setupQuery.isPending && } {setupQuery.isError && ( - Failed to load setup command + + Failed to load setup command + + )} {setupQuery.isSuccess && ( @@ -145,7 +217,7 @@ export default function GoogleScreen() { void handleCopy(); }} > - {copied ? 'Copied!' : 'Copy Command'} + {copied ? 'Copied!' : 'Copy command'} )} @@ -155,7 +227,7 @@ export default function GoogleScreen() { - Gmail Notifications + Gmail notifications 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 ? ( - - ) : ( - - )} - - {isPending && ( - - - Reason (optional) - { - if (val.length <= 500) { - pendingReasonRef.current = val; - } - }} - autoCapitalize="sentences" - autoCorrect - multiline - maxLength={500} - /> - - - - )} - + { + 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 + + + + ); + } + if (hasMoreVersions && !isAtLimitCap) { + return ( + + + + ); + } + 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 && ( - - )} - - {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. - - - - ); -} +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. + + + + ); +} 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({ - 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); + }} + /> + ); } @@ -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( + () => ( + + ), + [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); }} /> - 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 + + + )} - - - 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. + + + + )} @@ -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 }>) { - + ); } 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. + + + - { - 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. + + + + )} @@ -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({ ) : 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} + @@ -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. + + + + {storeOpenFailed && ( + + + Could not open the app store. + + + + + )} + + ); +} 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 - - - - - - ); -} 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' && ( + + )} + {entry.deployHint === 'upgrade_required' && ( + + )} ); })} 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 ? ( ); } @@ -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} + + + + + ); } - 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} - /> - - - - - - - - ); -} 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); + }} + /> ))} + )} + + {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}). + + )} + + + + )} + + ); +} 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 && ( + + )} + + {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 ( - <> - - - - ); -} - 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} + + + ); + } + return ( + + + + ); } return ( @@ -120,7 +129,7 @@ export function LoginScreen() { accessibilityLabel="Open sign-in page in browser" > - Open + Open in browser )} @@ -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" /> 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 )} - @@ -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} )} - @@ -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} - /> - - - - - - - - ); -} 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 + + ) + } + /> ); } 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} + + + + + + + + ); +} 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} + )} - - + + + ); } 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 ? ( - - - - - + + 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 })); + }} + /> + + + + + + - + ); } 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({ + } + /> + ) : 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