From 6903b70d4319eb8068276a0c762e0ed7e0cb6abb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 00:16:07 +0200 Subject: [PATCH 001/166] feat(mobile): add shared tab-screen scroll container --- apps/mobile/src/components/home/home-screen.tsx | 9 +++++---- apps/mobile/src/components/tab-screen.tsx | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/src/components/tab-screen.tsx diff --git a/apps/mobile/src/components/home/home-screen.tsx b/apps/mobile/src/components/home/home-screen.tsx index f77bc40aa1..cba38176cb 100644 --- a/apps/mobile/src/components/home/home-screen.tsx +++ b/apps/mobile/src/components/home/home-screen.tsx @@ -1,9 +1,11 @@ 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 { AppState, RefreshControl, View } from 'react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; +import { TabScreenScrollView } from '@/components/tab-screen'; + import { badgeBucketForInstance } from '@kilocode/notifications'; import { AgentSessionsSection } from '@/components/home/agent-sessions-section'; @@ -108,9 +110,8 @@ export function HomeScreen() { return ( - } > @@ -139,7 +140,7 @@ export function HomeScreen() { ) : null} )} - + ); } diff --git a/apps/mobile/src/components/tab-screen.tsx b/apps/mobile/src/components/tab-screen.tsx new file mode 100644 index 0000000000..7e498ac998 --- /dev/null +++ b/apps/mobile/src/components/tab-screen.tsx @@ -0,0 +1,16 @@ +import { Platform, ScrollView, type ScrollViewProps } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { getTabBarOverlayHeight } from '@/lib/tab-bar-layout'; + +export function useTabBarBottomPadding() { + const { bottom } = useSafeAreaInsets(); + return getTabBarOverlayHeight(bottom, Platform.OS) + 16; +} + +export function TabScreenScrollView({ contentContainerStyle, ...props }: ScrollViewProps) { + const paddingBottom = useTabBarBottomPadding(); + return ( + + ); +} From 65f411460b7c2f1ce5cb1a445dc2813153590f45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 00:16:12 +0200 Subject: [PATCH 002/166] fix(mobile): parse backend timestamps with parseTimestamp --- .../hooks/use-kilo-chat-token.test.ts | 20 +++++++++++++++++++ .../kilo-chat/hooks/use-kilo-chat-token.ts | 3 ++- 2 files changed, 22 insertions(+), 1 deletion(-) 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..3ae122e868 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,24 @@ describe('useKiloChatTokenResponseGetter', () => { unsubscribe(); }); + + it('parses PostgreSQL timestamp format for token expiry', async () => { + const futureTime = Date.now() + 3_600_000; + const pgTimestamp = new Date(futureTime).toISOString().replace('T', ' ').replace('Z', '+00'); + + const response = { + token: 'kilo-jwt', + userId: 'user-2', + expiresAt: pgTimestamp, + }; + + mocks.getItemAsync.mockResolvedValue('auth-token-2'); + mocks.getTokenQuery.mockResolvedValueOnce(response); + + const { useKiloChatTokenResponseGetter } = await import('./use-kilo-chat-token'); + const getTokenResponse = useKiloChatTokenResponseGetter(); + + const result = await getTokenResponse(); + expect(result).toBe(response); + }); }); 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); } From 82e7d47fc0ae5acf49be6999e2fedb00eb650359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 00:16:56 +0200 Subject: [PATCH 003/166] fix(mobile): gate sentry replay and screenshots on consent --- apps/mobile/src/app/_layout.tsx | 50 +++++++++++++++------- apps/mobile/src/lib/sentry-consent.test.ts | 23 ++++++++++ apps/mobile/src/lib/sentry-consent.ts | 29 +++++++++++++ 3 files changed, 87 insertions(+), 15 deletions(-) create mode 100644 apps/mobile/src/lib/sentry-consent.test.ts create mode 100644 apps/mobile/src/lib/sentry-consent.ts diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 6db22d1a31..5a55424165 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -19,7 +19,7 @@ import { } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; import { StatusBar } from 'expo-status-bar'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { View } from 'react-native'; import { AppRootProviders } from '@/components/app-root-providers'; @@ -40,30 +40,38 @@ import { setupNotificationResponseHandler, } from '@/lib/notifications'; import { resolvePendingNotificationNavigation } from '@/lib/pending-notification-navigation'; +import { sentryOptionsForConsent } from '@/lib/sentry-consent'; 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 decides whether +// those integrations run once, at Sentry.init() time, so `consented` starts +// `false` and RootLayoutNav below calls this again once consent is known or +// changes (accepted, declined, or revoked). +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 +106,18 @@ function RootLayoutNav() { } }, [fontsError]); + const appliedSentryConsentRef = useRef(null); + + useEffect(() => { + const consented = consentChecked && !needsConsent; + if (appliedSentryConsentRef.current === consented) { + return; + } + + appliedSentryConsentRef.current = consented; + initSentry(consented); + }, [consentChecked, needsConsent]); + const fontsReady = fontsLoaded || fontsError !== null; const isLoading = authLoading || updateChecking || !fontsReady; const inAuthGroup = segments[0] === '(auth)'; 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..b65a31e5a6 --- /dev/null +++ b/apps/mobile/src/lib/sentry-consent.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { sentryOptionsForConsent } from './sentry-consent'; + +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); + }); +}); diff --git a/apps/mobile/src/lib/sentry-consent.ts b/apps/mobile/src/lib/sentry-consent.ts new file mode 100644 index 0000000000..632c4b4d52 --- /dev/null +++ b/apps/mobile/src/lib/sentry-consent.ts @@ -0,0 +1,29 @@ +// 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 calls Sentry.init again +// with these options whenever the stored consent state changes. +export 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, + }; +} From 8915cf3dc0246e5afcb12f7cc94c666a1eb3ffb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 00:20:04 +0200 Subject: [PATCH 004/166] fix(mobile): add recovery paths to login and device auth --- apps/mobile/src/components/login-screen.tsx | 74 +++++++++------- .../mobile/src/lib/auth/poll-response.test.ts | 36 ++++++++ apps/mobile/src/lib/auth/poll-response.ts | 33 +++++++ apps/mobile/src/lib/auth/use-device-auth.ts | 87 +++++++++++++------ apps/mobile/vitest.config.ts | 1 + 5 files changed, 175 insertions(+), 56 deletions(-) create mode 100644 apps/mobile/src/lib/auth/poll-response.test.ts create mode 100644 apps/mobile/src/lib/auth/poll-response.ts diff --git a/apps/mobile/src/components/login-screen.tsx b/apps/mobile/src/components/login-screen.tsx index 26488e0a45..983c42ceb1 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 ( @@ -150,6 +159,9 @@ export function LoginScreen() { > Starting sign in... + )} @@ -162,7 +174,7 @@ export function LoginScreen() { {errorMessage(status, error)} - + )} 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..b399881b27 --- /dev/null +++ b/apps/mobile/src/lib/auth/poll-response.test.ts @@ -0,0 +1,36 @@ +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([400, 401])('treats %i as a terminal error', 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..89295baf09 --- /dev/null +++ b/apps/mobile/src/lib/auth/poll-response.ts @@ -0,0 +1,33 @@ +// 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. + if (httpStatus >= 400) { + return { status: 'error', message: 'Sign-in failed. Please try again.' }; + } + return { status: 'pending' }; +} diff --git a/apps/mobile/src/lib/auth/use-device-auth.ts b/apps/mobile/src/lib/auth/use-device-auth.ts index 71b085b30c..d26ba4d37c 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,10 +172,16 @@ export function useDeviceAuth(): DeviceAuthResult { verificationUrl: undefined, }); + const startAbort = new AbortController(); + const startTimeout = setTimeout(() => { + startAbort.abort(); + }, 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, }); if (!response.ok) { @@ -199,6 +234,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/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', From 3ddac733858e665a13bb289293281f87cfc07f28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 00:20:34 +0200 Subject: [PATCH 005/166] test(mobile): pin kilo-chat token expiry cache behavior --- .../hooks/use-kilo-chat-token.test.ts | 43 ++++++++++++++++--- 1 file changed, 36 insertions(+), 7 deletions(-) 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 3ae122e868..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 @@ -62,14 +62,11 @@ describe('useKiloChatTokenResponseGetter', () => { unsubscribe(); }); - it('parses PostgreSQL timestamp format for token expiry', async () => { - const futureTime = Date.now() + 3_600_000; - const pgTimestamp = new Date(futureTime).toISOString().replace('T', ' ').replace('Z', '+00'); - + it('caches the token when a PostgreSQL-format expiry is far in the future', async () => { const response = { token: 'kilo-jwt', userId: 'user-2', - expiresAt: pgTimestamp, + expiresAt: '2099-03-13 14:30:00+00', }; mocks.getItemAsync.mockResolvedValue('auth-token-2'); @@ -78,7 +75,39 @@ describe('useKiloChatTokenResponseGetter', () => { const { useKiloChatTokenResponseGetter } = await import('./use-kilo-chat-token'); const getTokenResponse = useKiloChatTokenResponseGetter(); - const result = await getTokenResponse(); - expect(result).toBe(response); + 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); }); }); From 4044e4a7c5260dfd8a42cf0178017042db361541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 00:21:25 +0200 Subject: [PATCH 006/166] fix(mobile): preserve agent drafts and block sends with failed attachments --- apps/mobile/src/app/(app)/agent-chat/new.tsx | 7 +++ .../agents/attachment-preview-strip.tsx | 29 +++++++-- .../src/components/agents/chat-composer.tsx | 62 +++++++++++++------ .../agents/session-detail-content.tsx | 4 +- .../use-agent-attachment-upload.ts | 35 ++++++++++- .../lib/agent-attachments/validate.test.ts | 21 ++++++- .../src/lib/agent-attachments/validate.ts | 5 ++ 7 files changed, 135 insertions(+), 28 deletions(-) diff --git a/apps/mobile/src/app/(app)/agent-chat/new.tsx b/apps/mobile/src/app/(app)/agent-chat/new.tsx index 3f604469a2..d8556d8404 100644 --- a/apps/mobile/src/app/(app)/agent-chat/new.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/new.tsx @@ -181,6 +181,10 @@ export default function NewSessionScreen() { toast.error('Wait for attachments to finish uploading.'); return; } + if (attachments.hasFailedAttachments) { + toast.error('Remove or retry failed attachments first.'); + return; + } if (prompt.startsWith('/') && attachments.attachments.length > 0) { toast.error('Attachments cannot be sent with slash commands.'); return; @@ -283,6 +287,9 @@ export default function NewSessionScreen() { onRemove={id => { attachments.removeAttachment(id); }} + onRetry={id => { + attachments.retryAttachment(id); + }} /> 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..b894bae27c 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, @@ -76,6 +77,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,7 +90,7 @@ 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; @@ -98,7 +100,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 +109,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 +120,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 +149,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 +186,11 @@ export function ChatComposer({ ) : null} {attachmentsEnabled ? ( - + ) : null} @@ -230,21 +250,27 @@ export function ChatComposer({ ) : ( { + 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/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 15142e0b41..c819b3254f 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -328,8 +328,10 @@ export function SessionDetailContent({ ...(supportsAttachments && attachments ? { attachments } : {}), }); captureEvent(MESSAGE_SENT_EVENT, { surface: analyticsSurface }); - } catch { + } catch (sendError) { toast.error('Failed to send message. Please try again.'); + // Rethrow so the composer knows the send failed and preserves the draft. + throw sendError; } }, [ diff --git a/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts b/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts index f3d6abd477..d6710ef10f 100644 --- a/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts +++ b/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts @@ -9,7 +9,11 @@ import { AGENT_ATTACHMENT_MIME_BY_EXTENSION, type AgentAttachmentExtension, } from '@/lib/agent-attachments/constants'; -import { canAddAttachments, classifyAttachment } from '@/lib/agent-attachments/validate'; +import { + canAddAttachments, + classifyAttachment, + hasFailedAttachments, +} from '@/lib/agent-attachments/validate'; export type AgentAttachmentKind = 'image' | 'document'; export type AgentAttachmentStatus = 'pending' | 'uploading' | 'uploaded' | 'error'; @@ -49,8 +53,10 @@ type UseAgentAttachmentUploadReturn = { attachments: AgentAttachment[]; addCandidates: (candidates: AgentAttachmentCandidate[]) => void; removeAttachment: (id: string) => void; + retryAttachment: (id: string) => void; reset: () => void; isUploading: boolean; + hasFailedAttachments: boolean; toWirePayload: () => AgentAttachmentWire | undefined; }; @@ -127,7 +133,7 @@ export function useAgentAttachmentUpload( }; const run = async () => { - update({ status: 'uploading' }); + update({ status: 'uploading', error: undefined }); try { const { key } = await uploadOne({ organizationId, @@ -212,6 +218,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 +248,28 @@ export function useAgentAttachmentUpload( const isUploading = attachments.some( item => item.status === 'pending' || item.status === 'uploading' ); + const failedAttachments = hasFailedAttachments(attachments); 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/agent-attachments/validate.test.ts b/apps/mobile/src/lib/agent-attachments/validate.test.ts index 548d79f3f3..4b29b9a063 100644 --- a/apps/mobile/src/lib/agent-attachments/validate.test.ts +++ b/apps/mobile/src/lib/agent-attachments/validate.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { canAddAttachments, classifyAttachment } from './validate'; +import { canAddAttachments, classifyAttachment, hasFailedAttachments } from './validate'; describe('classifyAttachment', () => { it('accepts a png by extension', () => { @@ -45,3 +45,22 @@ describe('canAddAttachments', () => { expect(canAddAttachments(5, 1)).toEqual({ ok: false, acceptedCount: 0 }); }); }); + +describe('hasFailedAttachments', () => { + it('blocks send when an attachment has errored', () => { + expect( + hasFailedAttachments([{ status: 'uploaded' }, { status: 'error' }, { status: 'pending' }]) + ).toBe(true); + }); + + it('allows send once the errored attachment is retried (status no longer error)', () => { + // Mirrors retryAttachment(id): the errored item transitions back to + // 'uploading' via the same status patch used for a fresh upload. + expect(hasFailedAttachments([{ status: 'uploaded' }, { status: 'uploading' }])).toBe(false); + }); + + it('returns false for an empty or all-clear attachment list', () => { + expect(hasFailedAttachments([])).toBe(false); + expect(hasFailedAttachments([{ status: 'uploaded' }])).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/agent-attachments/validate.ts b/apps/mobile/src/lib/agent-attachments/validate.ts index ce09c9d77f..fd6fc2cd2e 100644 --- a/apps/mobile/src/lib/agent-attachments/validate.ts +++ b/apps/mobile/src/lib/agent-attachments/validate.ts @@ -28,6 +28,11 @@ export function classifyAttachment(candidate: Candidate): ClassifiedAttachment { }; } +/** Pure so send-blocking can be unit-tested without rendering the upload hook. */ +export function hasFailedAttachments(attachments: { status: string }[]): boolean { + return attachments.some(item => item.status === 'error'); +} + export function canAddAttachments( currentCount: number, incomingCount: number From 4035790175e3066a6de483269f40a58d062943f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 00:24:28 +0200 Subject: [PATCH 007/166] fix(mobile): resolve kiloclaw instance context to explicit terminal states --- .../(app)/kiloclaw/[instance-id]/billing.tsx | 15 +++- .../kiloclaw/[instance-id]/changelog.tsx | 14 +++- .../kiloclaw/[instance-id]/dashboard.tsx | 17 +++- .../[instance-id]/settings/channels.tsx | 14 +++- .../[instance-id]/settings/device-pairing.tsx | 14 +++- .../[instance-id]/settings/exec-policy.tsx | 14 +++- .../[instance-id]/settings/google.tsx | 14 +++- .../[instance-id]/settings/model-list.tsx | 14 +++- .../[instance-id]/settings/secrets.tsx | 14 +++- .../[instance-id]/settings/version-pin.tsx | 14 +++- .../kilo-chat/conversation-screen.tsx | 4 +- .../kiloclaw/instance-context-boundary.tsx | 63 +++++++++++++++ .../src/components/kiloclaw/model-picker.tsx | 4 +- .../lib/hooks/instance-context-logic.test.ts | 78 +++++++++++++++++++ .../src/lib/hooks/instance-context-logic.ts | 38 +++++++++ .../src/lib/hooks/use-instance-context.ts | 47 ++++++----- 16 files changed, 338 insertions(+), 40 deletions(-) create mode 100644 apps/mobile/src/components/kiloclaw/instance-context-boundary.tsx create mode 100644 apps/mobile/src/lib/hooks/instance-context-logic.test.ts create mode 100644 apps/mobile/src/lib/hooks/instance-context-logic.ts 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..f1a825069c 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/billing.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/billing.tsx @@ -3,6 +3,7 @@ import { Linking, 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 { Button } from '@/components/ui/button'; @@ -94,12 +95,22 @@ function PlanDetails({ 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 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 ( 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..7ade145d51 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/changelog.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/changelog.tsx @@ -5,6 +5,7 @@ import { useLocalSearchParams } 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'; @@ -14,7 +15,9 @@ import { useKiloClawChangelog } from '@/lib/hooks/use-kiloclaw-queries'; export default function ChangelogScreen() { const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>(); - const { organizationId } = useInstanceContext(instanceId); + const instanceContext = useInstanceContext(instanceId); + const organizationId = + instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; const changelogQuery = useKiloClawChangelog(organizationId); const entries = changelogQuery.data; @@ -54,6 +57,15 @@ export default function ChangelogScreen() { ); } + if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { + return ( + + + + + ); + } + return ( 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..e591389eff 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx @@ -19,6 +19,7 @@ import { DashboardHero, ServiceDegradedBanner, } 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'; @@ -45,10 +46,13 @@ export default function DashboardScreen() { const colors = useThemeColors(); const { bottom } = useSafeAreaInsets(); const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>(); - const { organizationId, isResolved, isOrg } = useInstanceContext(instanceId); + const instanceContext = useInstanceContext(instanceId); + const organizationId = + instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; + 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); @@ -99,6 +103,15 @@ export default function DashboardScreen() { isRunning, ]); + if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { + return ( + + + + + ); + } + if (isLoading) { return ( 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..946032221a 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,6 +4,7 @@ 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'; @@ -13,7 +14,9 @@ import { useKiloClawChannelCatalog, useKiloClawMutations } from '@/lib/hooks/use export default function ChannelsScreen() { const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>(); - const { organizationId } = useInstanceContext(instanceId); + const instanceContext = useInstanceContext(instanceId); + const organizationId = + instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; const catalogQuery = useKiloClawChannelCatalog(organizationId); const mutations = useKiloClawMutations(organizationId); @@ -68,6 +71,15 @@ export default function ChannelsScreen() { ); } + if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { + return ( + + + + + ); + } + return ( 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..a15b04ed1c 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,6 +15,7 @@ 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'; @@ -37,7 +38,9 @@ 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 = + instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; const colors = useThemeColors(); const queryClient = useQueryClient(); const pairingQuery = useKiloClawPairing(organizationId); @@ -76,6 +79,15 @@ export default function DevicePairingScreen() { ); + if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { + return ( + + + + + ); + } + if (isLoading) { return ( 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..6d9b719deb 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 @@ -3,6 +3,7 @@ import { 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'; @@ -52,12 +53,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 = + instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; const statusQuery = useKiloClawStatus(organizationId); const mutations = useKiloClawMutations(organizationId); const currentPreset = resolvePreset(statusQuery.data?.execSecurity, statusQuery.data?.execAsk); + if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { + return ( + + + + + ); + } + if (statusQuery.isPending) { return ( 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..d09ae76f66 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 @@ -6,6 +6,7 @@ import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanim 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'; @@ -21,7 +22,9 @@ 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 = + instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; const statusQuery = useKiloClawStatus(organizationId); const mutations = useKiloClawMutations(organizationId); @@ -32,6 +35,15 @@ export default function GoogleScreen() { const setupQuery = useKiloClawGoogleSetup(organizationId, !statusQuery.isPending && !isConnected); + if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { + return ( + + + + + ); + } + if (statusQuery.isPending) { return ( 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..685354ca02 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 @@ -5,6 +5,7 @@ import { FlatList, Pressable, TextInput, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useLocalSearchParams, useRouter } 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'; @@ -24,7 +25,9 @@ type ModelItem = { export default function ModelListScreen() { const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>(); - const { organizationId } = useInstanceContext(instanceId); + const instanceContext = useInstanceContext(instanceId); + const organizationId = + instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; const router = useRouter(); const colors = useThemeColors(); const { bottom } = useSafeAreaInsets(); @@ -111,6 +114,15 @@ export default function ModelListScreen() { : []), ]; + 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..57c9a9b742 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,6 +4,7 @@ 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'; @@ -13,7 +14,9 @@ import { useKiloClawMutations, useKiloClawSecretCatalog } from '@/lib/hooks/use- export default function SecretsScreen() { const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>(); - const { organizationId } = useInstanceContext(instanceId); + const instanceContext = useInstanceContext(instanceId); + const organizationId = + instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; const mutations = useKiloClawMutations(organizationId); const catalogQuery = useKiloClawSecretCatalog(organizationId); const isLoading = catalogQuery.isPending; @@ -68,6 +71,15 @@ export default function SecretsScreen() { ); } + if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { + return ( + + + + + ); + } + return ( 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..1fed345b4d 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 @@ -4,6 +4,7 @@ import { Alert, FlatList, TextInput, 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 { Button } from '@/components/ui/button'; @@ -25,7 +26,9 @@ type VersionItem = NonNullable< export default function VersionPinScreen() { const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>(); - const { organizationId } = useInstanceContext(instanceId); + const instanceContext = useInstanceContext(instanceId); + const organizationId = + instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; const colors = useThemeColors(); const myPinQuery = useKiloClawMyPin(organizationId); const latestVersionQuery = useKiloClawLatestVersion(); @@ -37,6 +40,15 @@ export default function VersionPinScreen() { const isLoading = myPinQuery.isPending || latestVersionQuery.isPending; + if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { + return ( + + + + + ); + } + if (isLoading) { return ( diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index a59cb90688..41b93811e5 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -61,8 +61,8 @@ export function ConversationScreen({ const { bottom } = useSafeAreaInsets(); const instanceContext = useInstanceContext(sandboxId); const instanceStatusQuery = useKiloClawStatus( - instanceContext.organizationId, - instanceContext.isResolved + instanceContext.status === 'ready' ? instanceContext.organizationId : undefined, + instanceContext.status === 'ready' ); const { data: instances } = useAllKiloClawInstances(); const currentInstance = instances?.find(instance => instance.sandboxId === sandboxId); 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..1f54a2cfbd --- /dev/null +++ b/apps/mobile/src/components/kiloclaw/instance-context-boundary.tsx @@ -0,0 +1,63 @@ +import { type Href, useRouter } from 'expo-router'; +import { SearchX } from 'lucide-react-native'; +import { type ReactNode } from 'react'; +import { View } from 'react-native'; + +import { EmptyState } from '@/components/empty-state'; +import { QueryError } from '@/components/query-error'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { type InstanceContextResult } from '@/lib/hooks/use-instance-context'; + +type Props = { + context: InstanceContextResult; + children?: ReactNode; +}; + +/** + * Renders the terminal states of `useInstanceContext`: an error with retry, + * or an "instance not found" empty state (destroyed instance / stale deep + * link). Renders `children` for `loading`/`ready` — screens keep their own + * loading skeletons, this only covers the states they used to collapse into + * an eternal skeleton. + */ +export function InstanceContextBoundary({ context, children }: Readonly) { + const router = useRouter(); + + if (context.status === 'error') { + return ( + + { + context.refetch(); + }} + /> + + ); + } + + if (context.status === 'not_found') { + return ( + + { + router.replace('/(app)/(tabs)/(1_kiloclaw)' as Href); + }} + > + Back to instances + + } + /> + + ); + } + + return <>{children}; +} diff --git a/apps/mobile/src/components/kiloclaw/model-picker.tsx b/apps/mobile/src/components/kiloclaw/model-picker.tsx index 2d0fd41813..dbce60cca2 100644 --- a/apps/mobile/src/components/kiloclaw/model-picker.tsx +++ b/apps/mobile/src/components/kiloclaw/model-picker.tsx @@ -85,7 +85,9 @@ function PerformanceIndicator({ level, dotColor }: { level: number; dotColor: st export function ModelPicker() { const router = useRouter(); const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>(); - const { organizationId } = useInstanceContext(instanceId); + const instanceContext = useInstanceContext(instanceId); + const organizationId = + instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; const { data: config, isLoading } = useKiloClawConfig(organizationId); const mutations = useKiloClawMutations(organizationId); const colors = useThemeColors(); 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/use-instance-context.ts b/apps/mobile/src/lib/hooks/use-instance-context.ts index 8085284399..b071fb185d 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,23 @@ 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) { +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] + ); } From 9515582dd4ad1b66f694c24e47d4377909bcf532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 00:24:55 +0200 Subject: [PATCH 008/166] fix(mobile): advance security-agent settings baseline after save --- .../analysis-settings-screen.tsx | 5 ++++- .../automation-settings-screen.tsx | 9 +++++++-- .../notification-settings-screen.tsx | 5 ++++- .../repository-settings-screen.tsx | 18 ++++++++++-------- .../security-agent/settings-save-button.tsx | 7 +++++++ .../security-agent/sla-settings-screen.tsx | 5 ++++- .../src/lib/hooks/use-settings-back-guard.ts | 14 ++++++++++++++ .../src/security-agent/settings.test.ts | 18 ++++++++++++++++++ .../app-shared/src/security-agent/settings.ts | 12 ++++++++++++ 9 files changed, 80 insertions(+), 13 deletions(-) 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..f955f4eb2c 100644 --- a/apps/mobile/src/components/security-agent/analysis-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/analysis-settings-screen.tsx @@ -1,4 +1,5 @@ import { + advanceSettingsBaseline, getSettingsDirtyState, isPersonalSecurityScope, } from '@kilocode/app-shared/security-agent'; @@ -97,9 +98,10 @@ export function AnalysisSettingsScreen({ scope }: Readonly<{ scope: string }>) { const handleSave = async () => { await save.mutateAsync(patch); + initialConfigRef.current = advanceSettingsBaseline(initialConfigRef.current, patch); }; - const { onBack } = useSettingsBackGuard({ dirty, valid, onSave: handleSave }); + const { onBack, skipNextGuardRef } = useSettingsBackGuard({ dirty, valid, onSave: handleSave }); if (config.isError && !config.data) { return ( @@ -140,6 +142,7 @@ export function AnalysisSettingsScreen({ scope }: Readonly<{ scope: string }>) { valid={valid} pending={save.isPending} onSave={handleSave} + skipNextGuardRef={skipNextGuardRef} /> ) : undefined } 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..ff239bc3b2 100644 --- a/apps/mobile/src/components/security-agent/automation-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/automation-settings-screen.tsx @@ -1,4 +1,7 @@ -import { getSettingsDirtyState } from '@kilocode/app-shared/security-agent'; +import { + advanceSettingsBaseline, + getSettingsDirtyState, +} from '@kilocode/app-shared/security-agent'; import { useEffect, useRef, useState } from 'react'; import { ScrollView, View } from 'react-native'; import { toast } from 'sonner-native'; @@ -125,6 +128,7 @@ export function AutomationSettingsScreen({ scope }: Readonly<{ scope: string }>) const handleSave = async () => { const result = await save.mutateAsync(patch); + initialConfigRef.current = advanceSettingsBaseline(initialConfigRef.current, patch); if (result.existingFindingsQueuedCount) { toast.success( `${result.existingFindingsQueuedCount} existing finding${result.existingFindingsQueuedCount === 1 ? '' : 's'} queued for analysis.` @@ -132,7 +136,7 @@ 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 ( @@ -165,6 +169,7 @@ export function AutomationSettingsScreen({ scope }: Readonly<{ scope: string }>) valid={valid} pending={save.isPending} onSave={handleSave} + skipNextGuardRef={skipNextGuardRef} /> ) : undefined } diff --git a/apps/mobile/src/components/security-agent/notification-settings-screen.tsx b/apps/mobile/src/components/security-agent/notification-settings-screen.tsx index 5fd8a11c32..dafa62b8dd 100644 --- a/apps/mobile/src/components/security-agent/notification-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/notification-settings-screen.tsx @@ -1,4 +1,5 @@ import { + advanceSettingsBaseline, getSettingsDirtyState, isPersonalSecurityScope, isValidDayCount, @@ -125,9 +126,10 @@ export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string } const handleSave = async () => { await save.mutateAsync(patch); + initialConfigRef.current = advanceSettingsBaseline(initialConfigRef.current, patch); }; - const { onBack } = useSettingsBackGuard({ dirty, valid, onSave: handleSave }); + const { onBack, skipNextGuardRef } = useSettingsBackGuard({ dirty, valid, onSave: handleSave }); if (config.isError && !config.data) { return ( @@ -160,6 +162,7 @@ export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string } valid={valid} pending={save.isPending} onSave={handleSave} + skipNextGuardRef={skipNextGuardRef} /> ) : undefined } diff --git a/apps/mobile/src/components/security-agent/repository-settings-screen.tsx b/apps/mobile/src/components/security-agent/repository-settings-screen.tsx index 688d288502..1bca458610 100644 --- a/apps/mobile/src/components/security-agent/repository-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/repository-settings-screen.tsx @@ -1,4 +1,7 @@ -import { getSettingsDirtyState } from '@kilocode/app-shared/security-agent'; +import { + advanceSettingsBaseline, + getSettingsDirtyState, +} from '@kilocode/app-shared/security-agent'; import * as Haptics from 'expo-haptics'; import { Check, Lock } from 'lucide-react-native'; import { useEffect, useRef, useState } from 'react'; @@ -66,19 +69,17 @@ export function RepositorySettingsScreen({ scope }: Readonly<{ scope: string }>) useSecurityAgentSettingsRedirect(scope, config.data?.isEnabled); const valid = mode === 'all' || selectedIds.length > 0; + const patch = { repositorySelectionMode: mode, selectedRepositoryIds: selectedIds }; const dirty = hydratedRef.current && - getSettingsDirtyState( - initialConfigRef.current, - { repositorySelectionMode: mode, selectedRepositoryIds: selectedIds }, - valid - ) !== 'clean'; + getSettingsDirtyState(initialConfigRef.current, patch, valid) !== 'clean'; const handleSave = async () => { - await save.mutateAsync({ repositorySelectionMode: mode, selectedRepositoryIds: selectedIds }); + await save.mutateAsync(patch); + initialConfigRef.current = advanceSettingsBaseline(initialConfigRef.current, patch); }; - const { onBack } = useSettingsBackGuard({ dirty, valid, onSave: handleSave }); + const { onBack, skipNextGuardRef } = useSettingsBackGuard({ dirty, valid, onSave: handleSave }); if (config.isError && !config.data) { return ( @@ -123,6 +124,7 @@ export function RepositorySettingsScreen({ scope }: Readonly<{ scope: string }>) valid={valid} pending={save.isPending} onSave={handleSave} + skipNextGuardRef={skipNextGuardRef} /> ) : undefined } diff --git a/apps/mobile/src/components/security-agent/settings-save-button.tsx b/apps/mobile/src/components/security-agent/settings-save-button.tsx index cfda1c8895..f73b71119d 100644 --- a/apps/mobile/src/components/security-agent/settings-save-button.tsx +++ b/apps/mobile/src/components/security-agent/settings-save-button.tsx @@ -1,4 +1,5 @@ import { useRouter } from 'expo-router'; +import { type RefObject } from 'react'; import { ActivityIndicator } from 'react-native'; import { Button } from '@/components/ui/button'; @@ -19,11 +20,16 @@ export function SettingsSaveButton({ valid, pending, onSave, + skipNextGuardRef, }: Readonly<{ dirty: boolean; valid: boolean; pending: boolean; onSave: () => 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..4797e5dc76 100644 --- a/apps/mobile/src/components/security-agent/sla-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/sla-settings-screen.tsx @@ -1,4 +1,5 @@ import { + advanceSettingsBaseline, getSettingsDirtyState, isValidDayCount, parseDayCount, @@ -173,9 +174,10 @@ export function SlaSettingsScreen({ scope }: Readonly<{ scope: string }>) { const handleSave = async () => { await save.mutateAsync(patch); + initialConfigRef.current = advanceSettingsBaseline(initialConfigRef.current, patch); }; - const { onBack } = useSettingsBackGuard({ dirty, valid, onSave: handleSave }); + const { onBack, skipNextGuardRef } = useSettingsBackGuard({ dirty, valid, onSave: handleSave }); if (config.isError && !config.data) { return ( @@ -221,6 +223,7 @@ export function SlaSettingsScreen({ scope }: Readonly<{ scope: string }>) { valid={valid} pending={save.isPending} onSave={handleSave} + skipNextGuardRef={skipNextGuardRef} /> ) : undefined } 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/packages/app-shared/src/security-agent/settings.test.ts b/packages/app-shared/src/security-agent/settings.test.ts index b65975d51e..d132e334fa 100644 --- a/packages/app-shared/src/security-agent/settings.test.ts +++ b/packages/app-shared/src/security-agent/settings.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + advanceSettingsBaseline, canManageSecurityAgent, getSecurityAgentAuditUrl, getSecurityRepositoriesInScope, @@ -87,6 +88,23 @@ describe('getSettingsDirtyState', () => { }); }); +describe('advanceSettingsBaseline', () => { + const baseline = { slaCriticalDays: 15, selectedRepositoryIds: [1, 2] }; + + it('a saved patch advances the baseline, so the same patch reads clean afterwards', () => { + const patch = { slaCriticalDays: 20 }; + const advanced = advanceSettingsBaseline(baseline, patch); + expect(getSettingsDirtyState(advanced, patch, true)).toBe('clean'); + }); + + it('a failed save must not advance the baseline, so the same patch still reads dirty', () => { + const patch = { slaCriticalDays: 20 }; + // Baseline intentionally left untouched here, mirroring what a screen + // should do when save.mutateAsync rejects. + expect(getSettingsDirtyState(baseline, patch, true)).toBe('dirty-valid'); + }); +}); + describe('getSettingsBackGuardOptions', () => { it('offers no options when clean, so back navigates immediately', () => { expect(getSettingsBackGuardOptions('clean')).toEqual([]); diff --git a/packages/app-shared/src/security-agent/settings.ts b/packages/app-shared/src/security-agent/settings.ts index a2910eedd3..ce70014832 100644 --- a/packages/app-shared/src/security-agent/settings.ts +++ b/packages/app-shared/src/security-agent/settings.ts @@ -89,6 +89,18 @@ export function getSettingsDirtyState>( return valid ? 'dirty-valid' : 'dirty-invalid'; } +/** + * Advances a settings screen's dirty-comparison baseline after a successful + * save, so `getSettingsDirtyState(advanced, patch, valid)` reads 'clean' + * without waiting for the screen to re-hydrate from a refetched query. + */ +export function advanceSettingsBaseline>( + baseline: Partial, + patch: Partial +): Partial { + return { ...baseline, ...patch }; +} + type SettingsBackGuardOption = 'save' | 'discard' | 'keep-editing'; /** From 76f102052a045888134642ad4d7c7f69be9090f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 00:31:09 +0200 Subject: [PATCH 009/166] fix(mobile): gate model settings on resolved instance context --- .../[instance-id]/settings/model-list.tsx | 6 +++++- .../kiloclaw/[instance-id]/settings/model.tsx | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) 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 685354ca02..377445852c 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 @@ -40,11 +40,15 @@ export default function ModelListScreen() { const { data: models, - isLoading, + isLoading: isModelsLoading, isError, 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'; + const filtered = (models ?? []).filter((m: ModelItem) => { if (!searchFilter) { return true; 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..371bac8d71 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,9 +1,24 @@ 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'; export default function ModelSettingsScreen() { + const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>(); + const instanceContext = useInstanceContext(instanceId); + + if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { + return ( + + + + + ); + } + return ( From 077c66ee5793bcf3da7b11034523db7c645b5024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 00:33:32 +0200 Subject: [PATCH 010/166] fix(mobile): validate code-reviewer and security-agent route params --- .../[scope]/[platform]/focus-areas.tsx | 30 +++++++++++++--- .../code-reviewer/[scope]/[platform]/gate.tsx | 30 +++++++++++++--- .../[scope]/[platform]/index.tsx | 17 ++++++--- .../[scope]/[platform]/instructions.tsx | 26 +++++++++++--- .../[scope]/[platform]/repos.tsx | 30 +++++++++++++--- .../[scope]/[platform]/style.tsx | 30 +++++++++++++--- .../security-agent/[scope]/_layout.tsx | 16 +++++++-- .../security-agent/[scope]/dismiss/[id].tsx | 16 +++++++-- .../security-agent/[scope]/findings/[id].tsx | 16 +++++++-- .../src/components/invalid-route-state.tsx | 36 +++++++++++++++++++ apps/mobile/src/lib/code-reviewer-config.ts | 28 +++++++++++++-- .../mobile/src/lib/hooks/use-code-reviewer.ts | 3 +- apps/mobile/src/lib/route-params.test.ts | 29 +++++++++++++++ apps/mobile/src/lib/route-params.ts | 28 +++++++++++++++ 14 files changed, 298 insertions(+), 37 deletions(-) create mode 100644 apps/mobile/src/components/invalid-route-state.tsx create mode 100644 apps/mobile/src/lib/route-params.test.ts create mode 100644 apps/mobile/src/lib/route-params.ts 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]/focus-areas.tsx index a6affc3dc9..5f0e2c6e07 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]/focus-areas.tsx @@ -1,24 +1,46 @@ import * as Haptics from 'expo-haptics'; -import { useLocalSearchParams } from 'expo-router'; +import { type Href, useLocalSearchParams } from 'expo-router'; import { Check } from 'lucide-react-native'; import { Pressable, ScrollView, View } from 'react-native'; +import { InvalidRouteState } from '@/components/invalid-route-state'; import { ScreenHeader } from '@/components/screen-header'; import { Text } from '@/components/ui/text'; -import { asReviewerPlatform, REVIEW_FOCUS_AREAS } from '@/lib/code-reviewer-config'; +import { + parseReviewerPlatform, + 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'; +import { parseParam } from '@/lib/route-params'; export default function FocusAreasRoute() { - const { scope, platform: rawPlatform } = useLocalSearchParams<{ + const { scope: rawScope, platform: rawPlatform } = useLocalSearchParams<{ scope: string; platform: string; }>(); - const platform = asReviewerPlatform(rawPlatform); + const scope = parseParam(rawScope); + const platform = scope ? parseReviewerPlatform(scope, rawPlatform) : null; + + if (!scope || !platform) { + return ; + } + + return ; +} + +function FocusAreasRouteContent({ + scope, + platform, +}: Readonly<{ + scope: string; + platform: ReviewerPlatform; +}>) { const colors = useThemeColors(); const { data } = useReviewConfig(scope, platform); const save = useSaveReviewConfig(scope, platform); 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]/gate.tsx index 2abfefebf1..393bdfcb3b 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]/gate.tsx @@ -1,8 +1,14 @@ -import { useLocalSearchParams } from 'expo-router'; +import { type Href, useLocalSearchParams } from 'expo-router'; import { OptionList } from '@/components/code-reviewer/option-list'; -import { asReviewerPlatform, GATE_THRESHOLDS } from '@/lib/code-reviewer-config'; +import { InvalidRouteState } from '@/components/invalid-route-state'; +import { + GATE_THRESHOLDS, + parseReviewerPlatform, + type ReviewerPlatform, +} from '@/lib/code-reviewer-config'; import { useReviewConfig, useSaveReviewConfig } from '@/lib/hooks/use-code-reviewer'; +import { parseParam } from '@/lib/route-params'; const DESCRIPTIONS = { off: 'Never fail the PR check', @@ -12,11 +18,27 @@ const DESCRIPTIONS = { } as const; export default function GateThresholdRoute() { - const { scope, platform: rawPlatform } = useLocalSearchParams<{ + const { scope: rawScope, platform: rawPlatform } = useLocalSearchParams<{ scope: string; platform: string; }>(); - const platform = asReviewerPlatform(rawPlatform); + const scope = parseParam(rawScope); + const platform = scope ? parseReviewerPlatform(scope, rawPlatform) : null; + + if (!scope || !platform) { + return ; + } + + return ; +} + +function GateThresholdRouteContent({ + scope, + platform, +}: Readonly<{ + scope: string; + platform: ReviewerPlatform; +}>) { const { data } = useReviewConfig(scope, platform); const save = useSaveReviewConfig(scope, platform); 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..83c6c5eff0 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,21 @@ -import { useLocalSearchParams } from 'expo-router'; +import { type Href, useLocalSearchParams } from 'expo-router'; import { PlatformOverviewScreen } from '@/components/code-reviewer/platform-overview-screen'; -import { asReviewerPlatform } from '@/lib/code-reviewer-config'; +import { InvalidRouteState } from '@/components/invalid-route-state'; +import { parseReviewerPlatform } from '@/lib/code-reviewer-config'; +import { parseParam } from '@/lib/route-params'; export default function CodeReviewerPlatformRoute() { - const { scope, platform: rawPlatform } = useLocalSearchParams<{ + const { scope: rawScope, platform: rawPlatform } = useLocalSearchParams<{ scope: string; platform: string; }>(); - return ; + const scope = parseParam(rawScope); + const platform = scope ? parseReviewerPlatform(scope, rawPlatform) : null; + + if (!scope || !platform) { + return ; + } + + return ; } 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]/instructions.tsx index 3552b75a23..b5182248ae 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]/instructions.tsx @@ -1,14 +1,16 @@ -import { useLocalSearchParams, useRouter } from 'expo-router'; +import { type Href, useLocalSearchParams, useRouter } from 'expo-router'; import { useRef } from 'react'; import { ScrollView, TextInput, View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; +import { InvalidRouteState } from '@/components/invalid-route-state'; 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 { parseReviewerPlatform, type ReviewerPlatform } from '@/lib/code-reviewer-config'; import { useReviewConfig, useSaveReviewConfig } from '@/lib/hooks/use-code-reviewer'; +import { parseParam } from '@/lib/route-params'; // Mounted only once `data != null`, so useRef(initial) captures the real // loaded value instead of the pre-fetch default. @@ -53,11 +55,27 @@ function InstructionsEditor({ } export default function InstructionsRoute() { - const { scope, platform: rawPlatform } = useLocalSearchParams<{ + const { scope: rawScope, platform: rawPlatform } = useLocalSearchParams<{ scope: string; platform: string; }>(); - const platform = asReviewerPlatform(rawPlatform); + const scope = parseParam(rawScope); + const platform = scope ? parseReviewerPlatform(scope, rawPlatform) : null; + + if (!scope || !platform) { + return ; + } + + return ; +} + +function InstructionsRouteContent({ + scope, + platform, +}: Readonly<{ + scope: string; + platform: ReviewerPlatform; +}>) { const router = useRouter(); const { data } = useReviewConfig(scope, platform); const save = useSaveReviewConfig(scope, platform); 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 index 852a0b8a8d..a021095908 100644 --- 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 @@ -1,12 +1,17 @@ import * as Haptics from 'expo-haptics'; -import { useLocalSearchParams } from 'expo-router'; +import { type Href, useLocalSearchParams } from 'expo-router'; import { Check, Lock } from 'lucide-react-native'; import { Pressable, ScrollView, View } from 'react-native'; +import { InvalidRouteState } from '@/components/invalid-route-state'; 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 { + parseReviewerPlatform, + PLATFORM_CAPABILITIES, + type ReviewerPlatform, +} from '@/lib/code-reviewer-config'; import { useBitbucketReadiness, useGitHubRepositories, @@ -16,13 +21,30 @@ import { useSaveReviewConfig, } from '@/lib/hooks/use-code-reviewer'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { parseParam } from '@/lib/route-params'; export default function ReposRoute() { - const { scope, platform: rawPlatform } = useLocalSearchParams<{ + const { scope: rawScope, platform: rawPlatform } = useLocalSearchParams<{ scope: string; platform: string; }>(); - const platform = asReviewerPlatform(rawPlatform); + const scope = parseParam(rawScope); + const platform = scope ? parseReviewerPlatform(scope, rawPlatform) : null; + + if (!scope || !platform) { + return ; + } + + return ; +} + +function ReposRouteContent({ + scope, + platform, +}: Readonly<{ + scope: string; + platform: ReviewerPlatform; +}>) { const colors = useThemeColors(); const { data } = useReviewConfig(scope, platform); const save = useSaveReviewConfig(scope, platform); 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]/style.tsx index 2125403248..0f121abe9e 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]/style.tsx @@ -1,8 +1,14 @@ -import { useLocalSearchParams } from 'expo-router'; +import { type Href, useLocalSearchParams } from 'expo-router'; import { OptionList } from '@/components/code-reviewer/option-list'; -import { asReviewerPlatform, REVIEW_STYLES } from '@/lib/code-reviewer-config'; +import { InvalidRouteState } from '@/components/invalid-route-state'; +import { + parseReviewerPlatform, + REVIEW_STYLES, + type ReviewerPlatform, +} from '@/lib/code-reviewer-config'; import { useReviewConfig, useSaveReviewConfig } from '@/lib/hooks/use-code-reviewer'; +import { parseParam } from '@/lib/route-params'; const DESCRIPTIONS = { strict: 'Flag everything, hold a high bar', @@ -12,11 +18,27 @@ const DESCRIPTIONS = { } as const; export default function ReviewStyleRoute() { - const { scope, platform: rawPlatform } = useLocalSearchParams<{ + const { scope: rawScope, platform: rawPlatform } = useLocalSearchParams<{ scope: string; platform: string; }>(); - const platform = asReviewerPlatform(rawPlatform); + const scope = parseParam(rawScope); + const platform = scope ? parseReviewerPlatform(scope, rawPlatform) : null; + + if (!scope || !platform) { + return ; + } + + return ; +} + +function ReviewStyleRouteContent({ + scope, + platform, +}: Readonly<{ + scope: string; + platform: ReviewerPlatform; +}>) { const { data } = useReviewConfig(scope, platform); const save = useSaveReviewConfig(scope, platform); 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..8fb8e9147f 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,14 +1,20 @@ -import { Stack, useLocalSearchParams } from 'expo-router'; +import { type Href, Stack, useLocalSearchParams } from 'expo-router'; import { Platform, StatusBar, useWindowDimensions } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { InvalidRouteState } from '@/components/invalid-route-state'; import { SecurityAgentCommandObserver } from '@/components/security-agent/security-agent-command-observer'; +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 { scope: rawScope } = useLocalSearchParams<{ scope: string }>(); + const scope = parseParam(rawScope); const { height } = useWindowDimensions(); const { top } = useSafeAreaInsets(); // Mirrors apps/(app)/_layout.tsx's Android-safe full-sheet detent — Android @@ -18,6 +24,10 @@ export default function SecurityAgentScopeLayout() { height > 0 ? Math.max(0.5, (height - androidTopInset) / height) : 1; const fullSheetDetent = Platform.OS === 'android' ? androidFullSheetDetent : 1; + if (!scope) { + return ; + } + return ( <> 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]/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/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/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/hooks/use-code-reviewer.ts b/apps/mobile/src/lib/hooks/use-code-reviewer.ts index daa9cb94de..9110bdf43b 100644 --- a/apps/mobile/src/lib/hooks/use-code-reviewer.ts +++ b/apps/mobile/src/lib/hooks/use-code-reviewer.ts @@ -4,12 +4,13 @@ import { toast } from 'sonner-native'; import { buildSaveConfigInput, type ConfigPatch, + PERSONAL_SCOPE, type ReviewConfigData, type ReviewerPlatform, } from '@/lib/code-reviewer-config'; import { trpcClient, useTRPC } from '@/lib/trpc'; -export const PERSONAL_SCOPE = 'personal'; +export { PERSONAL_SCOPE }; function isPersonal(scope: string) { return scope === PERSONAL_SCOPE; 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..ffb8c4b20f --- /dev/null +++ b/apps/mobile/src/lib/route-params.ts @@ -0,0 +1,28 @@ +/** + * 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): string | null; +export function parseParam( + value: string | string[] | undefined, + allowed: readonly T[] +): T | null; +export function parseParam( + value: string | string[] | undefined, + allowed?: readonly string[] +): string | null { + if (typeof value !== 'string' || value.length === 0) { + return null; + } + if (allowed && !allowed.includes(value)) { + return null; + } + return value; +} From 9208835b7ee78535dc7066d8a0716234ec685a9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 00:33:58 +0200 Subject: [PATCH 011/166] fix(mobile): treat code-reviewer domain failures as mutation errors --- .../platform-overview-screen.tsx | 28 ++++++ .../mobile/src/lib/hooks/use-code-reviewer.ts | 98 ++++++++++++++----- 2 files changed, 102 insertions(+), 24 deletions(-) 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..8b4d716e8a 100644 --- a/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx @@ -15,6 +15,7 @@ import { openModelPicker } from '@/components/agents/model-selector'; import { BitbucketOverview } from '@/components/code-reviewer/bitbucket-overview'; import { ProviderConnectCard } from '@/components/code-reviewer/provider-connect-card'; 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'; @@ -25,6 +26,7 @@ import { useCanEditReviewer, useGitHubStatus, useGitLabStatus, + useGitLabWebhookWarning, useReviewConfig, useSaveReviewConfig, useToggleReviewer, @@ -43,6 +45,10 @@ export function PlatformOverviewScreen({ const toggle = useToggleReviewer(scope, platform); const save = useSaveReviewConfig(scope, platform); const canEdit = useCanEditReviewer(scope); + const { hasWebhookSyncWarning, dismissWebhookSyncWarning } = useGitLabWebhookWarning( + scope, + platform + ); const { models, isLoading: modelsLoading } = useAvailableModels( scope === PERSONAL_SCOPE ? undefined : scope ); @@ -181,6 +187,28 @@ export function PlatformOverviewScreen({ {!isLoading && connected && config.data != null && rows != null && ( + {platform === 'gitlab' && hasWebhookSyncWarning && ( + + + Webhook setup incomplete + + Some repositories may not receive automatic reviews. + + + + + )} + Automatic reviews diff --git a/apps/mobile/src/lib/hooks/use-code-reviewer.ts b/apps/mobile/src/lib/hooks/use-code-reviewer.ts index 9110bdf43b..a341da2396 100644 --- a/apps/mobile/src/lib/hooks/use-code-reviewer.ts +++ b/apps/mobile/src/lib/hooks/use-code-reviewer.ts @@ -147,18 +147,26 @@ export function useToggleReviewer(scope: string, platform: ReviewerPlatform) { 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); @@ -178,36 +186,78 @@ 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 until the caller dismisses it (e.g. + * after the user retries webhook setup). + */ +export function useGitLabWebhookWarning(scope: string, platform: ReviewerPlatform) { + const queryClient = useQueryClient(); + const queryKey = gitLabWebhookWarningQueryKey(scope, platform); + const { data } = useQuery({ + queryKey, + queryFn: () => false, + initialData: false, + staleTime: Infinity, + }); + + return { + hasWebhookSyncWarning: data, + dismissWebhookSyncWarning: () => { + queryClient.setQueryData(queryKey, false); + }, + }; +} + export function useSaveReviewConfig(scope: string, platform: ReviewerPlatform) { const queryClient = useQueryClient(); const queryKey = useReviewConfigQueryKey(scope, platform); + const webhookWarningQueryKey = gitLabWebhookWarningQueryKey(scope, platform); return useMutation({ - // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule - mutationFn: (patch: ConfigPatch) => { + mutationFn: async (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' + // 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 trpcClient.personalReviewAgent.saveReviewConfig.mutate({ - ...input, - platform: toPersonalPlatform(platform), - selectedRepositoryIds: numericSelectedRepositoryIds, - }); } - return trpcClient.organizations.reviewAgent.saveReviewConfig.mutate({ - ...input, - organizationId: scope, - }); + return result; }, onMutate: async patch => { await queryClient.cancelQueries({ queryKey }); From 9c43278d6b41417f242414e89d8b8dde7f4489f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 00:37:26 +0200 Subject: [PATCH 012/166] fix(mobile): skeleton model picker until instance context resolves --- apps/mobile/src/components/kiloclaw/model-picker.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/components/kiloclaw/model-picker.tsx b/apps/mobile/src/components/kiloclaw/model-picker.tsx index dbce60cca2..8fde8bdea7 100644 --- a/apps/mobile/src/components/kiloclaw/model-picker.tsx +++ b/apps/mobile/src/components/kiloclaw/model-picker.tsx @@ -102,7 +102,10 @@ export function ModelPicker() { mutations.updateModel.mutate({ kilocodeDefaultModel: addModelPrefix(modelId) }); }; - if (isLoading) { + // Disabled queries (organizationId unresolved) have isLoading === false, so + // also skeleton while instance context is loading — otherwise the cards render + // interactive but taps silently no-op. + if (isLoading || instanceContext.status !== 'ready') { return ( From 11af150cb6b13c4555d2e8e87f900425343bda3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 00:37:30 +0200 Subject: [PATCH 013/166] fix(mobile): repair home-screen test mocks and unused export --- apps/mobile/src/components/home/home-screen.test.ts | 4 ++++ apps/mobile/src/lib/sentry-consent.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/components/home/home-screen.test.ts b/apps/mobile/src/components/home/home-screen.test.ts index 60ffb7fc59..cba1abafc5 100644 --- a/apps/mobile/src/components/home/home-screen.test.ts +++ b/apps/mobile/src/components/home/home-screen.test.ts @@ -50,6 +50,10 @@ vi.mock('@/components/kiloclaw/status-badge', () => ({ 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/lib/sentry-consent.ts b/apps/mobile/src/lib/sentry-consent.ts index 632c4b4d52..d1ec6b79d3 100644 --- a/apps/mobile/src/lib/sentry-consent.ts +++ b/apps/mobile/src/lib/sentry-consent.ts @@ -3,7 +3,7 @@ // "anonymous performance and crash data" — see consent-card.tsx). This is // the pure decision function; src/app/_layout.tsx calls Sentry.init again // with these options whenever the stored consent state changes. -export type SentryConsentOptions = { +type SentryConsentOptions = { readonly replaysSessionSampleRate: number; readonly replaysOnErrorSampleRate: number; readonly attachScreenshot: boolean; From 33805c68146faa950e3bf49d58d52a39bcb5ca39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:17:24 +0200 Subject: [PATCH 014/166] fix(mobile): respect bottom safe area in kilo-chat composer --- .../kilo-chat/message-input-layout.test.ts | 13 +++++++++++-- .../components/kilo-chat/message-input-layout.ts | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) 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); } From 1f53c385d79c25a293aec31d0456a9c7dd820dd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:18:13 +0200 Subject: [PATCH 015/166] refactor(mobile): drop compact-session-row passthrough --- .../home/agent-sessions-section.tsx | 10 +++--- .../components/home/compact-session-row.tsx | 35 ------------------- 2 files changed, 5 insertions(+), 40 deletions(-) delete mode 100644 apps/mobile/src/components/home/compact-session-row.tsx diff --git a/apps/mobile/src/components/home/agent-sessions-section.tsx b/apps/mobile/src/components/home/agent-sessions-section.tsx index 11a7b6d71b..e940862cc3 100644 --- a/apps/mobile/src/components/home/agent-sessions-section.tsx +++ b/apps/mobile/src/components/home/agent-sessions-section.tsx @@ -5,8 +5,8 @@ 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 { type ActiveSession, type StoredSession, @@ -182,10 +182,10 @@ export function AgentSessionsSection({ organizationId }: Readonly - { navigateTo(session.id); @@ -200,11 +200,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 ( - - ); -} From 8163ddf663ce91a816c066d6459f24e007f1a4de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:19:21 +0200 Subject: [PATCH 016/166] feat(mobile): screen-header heading semantics and fallback back action --- apps/mobile/src/components/screen-header.tsx | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/components/screen-header.tsx b/apps/mobile/src/components/screen-header.tsx index 798da347aa..869ee61b07 100644 --- a/apps/mobile/src/components/screen-header.tsx +++ b/apps/mobile/src/components/screen-header.tsx @@ -1,4 +1,4 @@ -import { useRouter } from 'expo-router'; +import { type Href, useRouter } from 'expo-router'; import { ChevronDown, ChevronLeft } from 'lucide-react-native'; import { Platform, Pressable, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -21,6 +21,10 @@ type ScreenHeaderProps = { onBack?: () => void; onTitlePress?: () => void; backIcon?: 'back' | 'close'; + /** Accessibility label for the back button. Defaults to "Close" when `backIcon` resolves to `close`, otherwise "Go back". */ + backLabel?: string; + /** Where to navigate when there's no back history (direct-entry/deep-link screens). Renders the back button and calls `router.replace(fallbackHref)` instead of `router.back()`. */ + fallbackHref?: Href; /** Extra classes on the outer header container. Overrides the default `px-4` for screens that need a different horizontal inset. */ className?: string; }; @@ -35,12 +39,15 @@ export function ScreenHeader({ onBack, onTitlePress, backIcon, + backLabel, + fallbackHref, className, }: Readonly) { const insets = useSafeAreaInsets(); const router = useRouter(); const colors = useThemeColors(); - const canGoBack = showBackButton ?? router.canGoBack(); + const routerCanGoBack = router.canGoBack(); + const canGoBack = showBackButton ?? (routerCanGoBack || fallbackHref !== undefined); // iOS modals are presented as cards already inset from the status bar const paddingTop = modal && Platform.OS === 'ios' ? 32 : insets.top + 8; @@ -48,6 +55,7 @@ export function ScreenHeader({ // When `backIcon` isn't specified, fall back to the historical behaviour // where iOS modals get a ChevronDown and everything else gets a ChevronLeft. const resolvedBackIcon = backIcon ?? (modal && Platform.OS === 'ios' ? 'close' : 'back'); + const resolvedBackLabel = backLabel ?? (resolvedBackIcon === 'close' ? 'Close' : 'Go back'); const titleClass = size === 'large' @@ -57,7 +65,7 @@ export function ScreenHeader({ let titleNode: React.ReactNode = null; if (title != null) { const titleText = ( - + {title} ); @@ -86,13 +94,15 @@ export function ScreenHeader({ onPress={() => { if (onBack) { onBack(); - } else { + } else if (routerCanGoBack) { router.back(); + } else if (fallbackHref) { + router.replace(fallbackHref); } }} hitSlop={12} accessibilityRole="button" - accessibilityLabel="Go back" + accessibilityLabel={resolvedBackLabel} className="-ml-1 mr-1 active:opacity-70" > {resolvedBackIcon === 'close' ? ( From 6b6211272e90b1e176f466d7e946dc3f65d34743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:20:28 +0200 Subject: [PATCH 017/166] fix(mobile): toaster ordering and Kilo-token styling --- .../src/components/app-root-providers.tsx | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) 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 }, + }} + /> From bee6c9f5193830dd58c048738349295ad079ca27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:20:59 +0200 Subject: [PATCH 018/166] feat(mobile): 44pt button targets and loading state --- apps/mobile/src/components/ui/button.tsx | 62 +++++++++++++++++++++--- 1 file changed, 54 insertions(+), 8 deletions(-) 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} + ); } From 2101281919f0203b95fad7992ad20506253ee974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:21:09 +0200 Subject: [PATCH 019/166] feat(mobile): uncontrolled FormField primitive --- apps/mobile/src/components/ui/form-field.tsx | 47 ++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 apps/mobile/src/components/ui/form-field.tsx 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..ffcab11c7e --- /dev/null +++ b/apps/mobile/src/components/ui/form-field.tsx @@ -0,0 +1,47 @@ +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; + }; + +/** + * 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, ...props }: Readonly) { + const colors = useThemeColors(); + + return ( + + {label} + + {error ? {error} : null} + + ); +} + +export { FormField }; +export type { FormFieldProps }; From 03ca5226f151d52c3b408a114b3c1126b2877ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:22:13 +0200 Subject: [PATCH 020/166] fix(mobile): hide chevron and dim disabled ConfigureRow --- apps/mobile/src/components/ui/configure-row.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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} From 70a4cc8d3f064f685b40e81bb63d519b651e742b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:22:47 +0200 Subject: [PATCH 021/166] fix(mobile): contrast-safe semantic tokens and input boundaries --- DESIGN.md | 4 ++++ apps/mobile/src/global.css | 24 +++++++++---------- apps/mobile/src/lib/hooks/use-theme-colors.ts | 8 +++---- 3 files changed, 20 insertions(+), 16 deletions(-) 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/global.css b/apps/mobile/src/global.css index 01d5431087..144706210c 100644 --- a/apps/mobile/src/global.css +++ b/apps/mobile/src/global.css @@ -19,15 +19,15 @@ --secondary: #f0eee6; --secondary-foreground: #14130f; --muted: #f0eee6; - --muted-foreground: #7a756b; + --muted-foreground: #6f6a61; --accent: #4f5a10; --accent-foreground: #ffffff; --primary: #4f5a10; --primary-foreground: #ffffff; - --destructive: #c25647; + --destructive: #be4e3f; --destructive-foreground: #ffffff; --border: rgba(20, 15, 10, 0.09); - --input: rgba(20, 15, 10, 0.09); + --input: rgba(20, 15, 10, 0.45); --ring: #4f5a10; --radius: 0.625rem; @@ -37,16 +37,16 @@ --hair-soft: rgba(20, 15, 10, 0.05); --accent-soft: #e8f27a; --accent-soft-foreground: #1a1a10; - --good: #2f9a5f; + --good: #278150; --good-foreground: #ffffff; - --good-tile-bg: #2f9a5f1a; - --good-tile-border: #2f9a5f33; - --warn: #b27214; + --good-tile-bg: #2781501a; + --good-tile-border: #27815033; + --warn: #9f6612; --warn-foreground: #ffffff; - --warn-tile-bg: #b272141a; - --warn-tile-border: #b2721433; - --danger-tile-bg: #c256471a; - --danger-tile-border: #c2564733; + --warn-tile-bg: #9f66121a; + --warn-tile-border: #9f661233; + --danger-tile-bg: #be4e3f1a; + --danger-tile-border: #be4e3f33; /* Per-agent hues + pre-tinted tile pairs. * The *-tile-bg (10% alpha) and *-tile-border (20% alpha) tokens exist @@ -94,7 +94,7 @@ --destructive: #f28b7a; --destructive-foreground: #1a1a10; --border: rgba(255, 255, 255, 0.07); - --input: rgba(255, 255, 255, 0.07); + --input: rgba(255, 255, 255, 0.35); --ring: #e8f27a; --radius: 0.625rem; diff --git a/apps/mobile/src/lib/hooks/use-theme-colors.ts b/apps/mobile/src/lib/hooks/use-theme-colors.ts index 2b23187c5a..dde8cb8358 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,8 @@ const lightColors = { hairSoft: 'rgba(20, 15, 10, 0.05)', accentSoft: '#E8F27A', accentSoftForeground: '#1A1A10', - good: '#2F9A5F', - warn: '#B27214', + good: '#278150', + warn: '#9F6612', // Per-agent hues (full-opacity only — tile bg/border live in CSS tokens) agentYuki: '#6B4FD6', From f5d4c8d2fc8e90a61cee06280ea1fe8d9e25e148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:26:31 +0200 Subject: [PATCH 022/166] feat(mobile): spinning icon with reduced-motion support --- .../agents/child-session-section.tsx | 3 +- .../security-agent/finding-analysis-panel.tsx | 1 + .../finding-remediation-panel.tsx | 2 + .../components/security-agent/finding-row.tsx | 15 +++++- .../security-agent/finding-status-badge.tsx | 11 +++- apps/mobile/src/components/ui/skeleton.tsx | 10 +++- .../src/components/ui/spinning-icon.tsx | 50 +++++++++++++++++++ 7 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/src/components/ui/spinning-icon.tsx diff --git a/apps/mobile/src/components/agents/child-session-section.tsx b/apps/mobile/src/components/agents/child-session-section.tsx index d424bd0ec0..40401d97e1 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 ? ( - + ) : ( )} 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..afc2bd33d7 100644 --- a/apps/mobile/src/components/security-agent/finding-analysis-panel.tsx +++ b/apps/mobile/src/components/security-agent/finding-analysis-panel.tsx @@ -143,6 +143,7 @@ export function FindingAnalysisPanel({ icon={presentation.icon} label={presentation.title} tone={presentation.tone} + spinning={presentation.spinning} /> {presentation.description} 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..c954e6d2f3 100644 --- a/apps/mobile/src/components/security-agent/finding-remediation-panel.tsx +++ b/apps/mobile/src/components/security-agent/finding-remediation-panel.tsx @@ -93,6 +93,7 @@ export function FindingRemediationPanel({ icon={presentation.icon} label={presentation.label} tone={presentation.tone} + spinning={presentation.spinning} /> {remediationSummary?.outcomeSummary ? ( @@ -215,6 +216,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..9c9a547908 100644 --- a/apps/mobile/src/components/security-agent/finding-row.tsx +++ b/apps/mobile/src/components/security-agent/finding-row.tsx @@ -12,6 +12,7 @@ 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'; @@ -191,14 +192,24 @@ export function FindingRow({ - + {analysis.label} {deadline && DeadlineIcon && ( - + {deadline.label} diff --git a/apps/mobile/src/components/security-agent/finding-status-badge.tsx b/apps/mobile/src/components/security-agent/finding-status-badge.tsx index e82f1b7700..443b1bc0a0 100644 --- a/apps/mobile/src/components/security-agent/finding-status-badge.tsx +++ b/apps/mobile/src/components/security-agent/finding-status-badge.tsx @@ -6,6 +6,7 @@ import { FINDING_TONE_TEXT_CLASS, findingToneColor, } from '@/components/security-agent/finding-tone'; +import { SpinningIcon } from '@/components/ui/spinning-icon'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { cn } from '@/lib/utils'; @@ -15,6 +16,8 @@ type FindingStatusBadgeProps = { label: string; tone: FindingTone; size?: number; + /** Set from the presentation's `spinning` field for in-progress states (e.g. queued/analyzing). */ + spinning?: boolean; }; export function FindingStatusBadge({ @@ -22,13 +25,19 @@ export function FindingStatusBadge({ label, tone, size = 14, + spinning = false, }: Readonly) { const colors = useThemeColors(); const Icon = FINDING_ICONS[icon]; return ( - + {label} ); diff --git a/apps/mobile/src/components/ui/skeleton.tsx b/apps/mobile/src/components/ui/skeleton.tsx index 4b4f47b85c..118b32f6a0 100644 --- a/apps/mobile/src/components/ui/skeleton.tsx +++ b/apps/mobile/src/components/ui/skeleton.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import Animated, { useAnimatedStyle, + useReducedMotion, useSharedValue, withRepeat, withTiming, @@ -13,14 +14,19 @@ type SkeletonProps = { }; export function Skeleton({ className }: Readonly) { + const reducedMotion = useReducedMotion(); const opacity = useSharedValue(0.4); useEffect(() => { + if (reducedMotion) { + return; + } opacity.value = withRepeat(withTiming(1, { duration: 1000 }), -1, true); - }, [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..809426df8f --- /dev/null +++ b/apps/mobile/src/components/ui/spinning-icon.tsx @@ -0,0 +1,50 @@ +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; + } + }, [isAnimating, rotation]); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotation.value}deg` }], + })); + + return ( + + + + ); +} From de7de406b22951f8563219a57da90f835163db19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:27:05 +0200 Subject: [PATCH 023/166] fix(mobile): tab-bar clearance via shared container --- .../[scope]/[platform]/focus-areas.tsx | 7 ++++--- .../[scope]/[platform]/instructions.tsx | 9 +++++---- .../[scope]/[platform]/repos.tsx | 7 ++++--- .../code-reviewer/bitbucket-overview.tsx | 9 +++++---- .../code-reviewer/manual-review-screen.tsx | 9 +++++---- .../components/code-reviewer/option-list.tsx | 7 ++++--- .../code-reviewer/platform-list-screen.tsx | 7 ++++--- .../platform-overview-screen.tsx | 7 ++++--- .../code-reviewer/review-detail-screen.tsx | 19 ++++++++++--------- .../code-reviewer/review-list-screen.tsx | 7 ++++--- .../code-reviewer/scope-list-screen.tsx | 7 ++++--- .../kiloclaw/instance-list-screen.tsx | 9 +++++---- .../organization/credit-activity-screen.tsx | 5 ++++- .../components/organization/hub-screen.tsx | 9 +++++---- .../organization/invoices-screen.tsx | 5 ++++- .../organization/members-screen.tsx | 9 +++++---- .../analysis-settings-screen.tsx | 7 ++++--- .../automation-settings-screen.tsx | 7 ++++--- .../security-agent/dashboard-screen.tsx | 9 +++++---- .../security-agent/finding-detail-screen.tsx | 7 ++++--- .../security-agent/finding-list-screen.tsx | 5 ++++- .../notification-settings-screen.tsx | 9 +++++---- .../repository-settings-screen.tsx | 7 ++++--- .../security-agent/scope-list-screen.tsx | 7 ++++--- .../settings-overview-screen.tsx | 7 ++++--- .../security-agent/sla-settings-screen.tsx | 9 +++++---- apps/mobile/src/components/tab-screen.tsx | 1 + 27 files changed, 120 insertions(+), 87 deletions(-) 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]/focus-areas.tsx index 5f0e2c6e07..17ed8ed689 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]/focus-areas.tsx @@ -1,11 +1,12 @@ import * as Haptics from 'expo-haptics'; import { type Href, useLocalSearchParams } from 'expo-router'; import { Check } from 'lucide-react-native'; -import { Pressable, ScrollView, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { InvalidRouteState } from '@/components/invalid-route-state'; import { ScreenHeader } from '@/components/screen-header'; import { Text } from '@/components/ui/text'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { parseReviewerPlatform, REVIEW_FOCUS_AREAS, @@ -62,7 +63,7 @@ function FocusAreasRouteContent({ return ( - + Leave all unselected to review everything. @@ -78,7 +79,7 @@ function FocusAreasRouteContent({ ))} - + ); } 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]/instructions.tsx index b5182248ae..f9c8ba477c 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]/instructions.tsx @@ -1,6 +1,6 @@ import { type Href, 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 { InvalidRouteState } from '@/components/invalid-route-state'; @@ -8,6 +8,7 @@ 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 { parseReviewerPlatform, type ReviewerPlatform } from '@/lib/code-reviewer-config'; import { useReviewConfig, useSaveReviewConfig } from '@/lib/hooks/use-code-reviewer'; import { parseParam } from '@/lib/route-params'; @@ -83,9 +84,9 @@ function InstructionsRouteContent({ return ( - @@ -107,7 +108,7 @@ function InstructionsRouteContent({ /> )} - + ); } 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 index a021095908..1c2d78e75c 100644 --- 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 @@ -1,12 +1,13 @@ import * as Haptics from 'expo-haptics'; import { type Href, useLocalSearchParams } from 'expo-router'; import { Check, Lock } from 'lucide-react-native'; -import { Pressable, ScrollView, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { InvalidRouteState } from '@/components/invalid-route-state'; import { ScreenHeader } from '@/components/screen-header'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { parseReviewerPlatform, PLATFORM_CAPABILITIES, @@ -90,7 +91,7 @@ function ReposRouteContent({ return ( - + {capabilities.selectionModePicker && (['all', 'selected'] as const).map(option => ( )} - + ); } diff --git a/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx b/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx index 1138bdfec4..42790c9798 100644 --- a/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx +++ b/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx @@ -7,7 +7,7 @@ import { 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'; @@ -16,6 +16,7 @@ import { ScreenHeader } from '@/components/screen-header'; 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 } from '@/lib/code-reviewer-config'; import { useAvailableModels } from '@/lib/hooks/use-available-models'; import { @@ -116,9 +117,9 @@ export function BitbucketOverview({ return ( - @@ -188,7 +189,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..f43f48a9e6 100644 --- a/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx @@ -2,7 +2,7 @@ import * as Haptics from 'expo-haptics'; import { type Href, useRouter } from 'expo-router'; import { Check } from 'lucide-react-native'; import { useRef, useState } from 'react'; -import { Pressable, ScrollView, TextInput, View } from 'react-native'; +import { Pressable, TextInput, View } from 'react-native'; import { toast } from 'sonner-native'; import { matchesCodeReviewUrlSuffix } from '@kilocode/app-shared/code-review'; @@ -10,6 +10,7 @@ import { ModelSelector } from '@/components/agents/model-selector'; import { ScreenHeader } from '@/components/screen-header'; import { Button } from '@/components/ui/button'; 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 { @@ -104,9 +105,9 @@ export function ManualReviewScreen({ scope }: Readonly<{ scope: string }>) { return ( - @@ -210,7 +211,7 @@ export function ManualReviewScreen({ scope }: Readonly<{ scope: string }>) { > Start review - + ); } diff --git a/apps/mobile/src/components/code-reviewer/option-list.tsx b/apps/mobile/src/components/code-reviewer/option-list.tsx index f704f23ec3..9243c6599c 100644 --- a/apps/mobile/src/components/code-reviewer/option-list.tsx +++ b/apps/mobile/src/components/code-reviewer/option-list.tsx @@ -1,10 +1,11 @@ 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 { Pressable, View } from 'react-native'; import { ScreenHeader } from '@/components/screen-header'; import { Text } from '@/components/ui/text'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; type OptionListProps = { @@ -30,7 +31,7 @@ export function OptionList({ return ( - + {options.map(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..872748b247 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, @@ -64,7 +65,7 @@ export function PlatformListScreen({ scope }: Readonly<{ scope: string }>) { return ( - + Platforms @@ -114,7 +115,7 @@ export function PlatformListScreen({ scope }: Readonly<{ scope: string }>) { /> - + ); } 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 8b4d716e8a..556eccef58 100644 --- a/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx @@ -8,7 +8,7 @@ import { 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'; @@ -19,6 +19,7 @@ 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 { @@ -158,7 +159,7 @@ export function PlatformOverviewScreen({ return ( - + {isLoading && ( @@ -266,7 +267,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..b3d7024fe5 100644 --- a/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx @@ -1,5 +1,5 @@ import * as Haptics from 'expo-haptics'; -import { Alert, Linking, Pressable, ScrollView, View } from 'react-native'; +import { Alert, Linking, Pressable, View } from 'react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { @@ -11,6 +11,7 @@ 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 { useCancelReview, useRetriggerReview, useReviewDetail } from '@/lib/hooks/use-code-reviews'; import { cn, parseTimestamp, timeAgo } from '@/lib/utils'; @@ -55,7 +56,7 @@ export function ReviewDetailScreen({ return ( - + { @@ -64,7 +65,7 @@ export function ReviewDetailScreen({ > {error.message}. Tap to retry. - + ); } @@ -73,12 +74,12 @@ export function ReviewDetailScreen({ return ( - + - + ); } @@ -87,7 +88,7 @@ export function ReviewDetailScreen({ return ( - + { @@ -96,7 +97,7 @@ export function ReviewDetailScreen({ > {data.error}. Tap to retry. - + ); } @@ -109,7 +110,7 @@ export function ReviewDetailScreen({ return ( - + {review.pr_title} @@ -195,7 +196,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..0096c7e4e5 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 { @@ -12,6 +12,7 @@ import { EmptyState } from '@/components/empty-state'; import { ScreenHeader } from '@/components/screen-header'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { useReviewList } from '@/lib/hooks/use-code-reviews'; import { cn, parseTimestamp, timeAgo } from '@/lib/utils'; @@ -48,7 +49,7 @@ export function ReviewListScreen({ scope }: Readonly<{ scope: string }>) { return ( - + {isLoading && ( @@ -128,7 +129,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..a2ee1b6ea4 100644 --- a/apps/mobile/src/components/code-reviewer/scope-list-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/scope-list-screen.tsx @@ -1,11 +1,12 @@ 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 { 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'; @@ -21,7 +22,7 @@ export function ScopeListScreen() { return ( - + )) )} - + ); } diff --git a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx index 2823adb6df..4d18d1e838 100644 --- a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx @@ -1,6 +1,6 @@ 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'; @@ -10,6 +10,7 @@ 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 ClawInstance } from '@/lib/hooks/use-instance-context'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; @@ -102,9 +103,9 @@ export function InstanceListScreen({ - } > @@ -140,7 +141,7 @@ export function InstanceListScreen({ unreadByBadgeBucket={unreadByBadgeBucket} showCount={showSectionCounts} /> - + ); diff --git a/apps/mobile/src/components/organization/credit-activity-screen.tsx b/apps/mobile/src/components/organization/credit-activity-screen.tsx index 1c13be6ca2..84f9a5a4cd 100644 --- a/apps/mobile/src/components/organization/credit-activity-screen.tsx +++ b/apps/mobile/src/components/organization/credit-activity-screen.tsx @@ -5,6 +5,7 @@ import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { EmptyState } from '@/components/empty-state'; 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 { @@ -64,6 +65,7 @@ function CreditRow({ transaction }: Readonly<{ transaction: CreditTransaction }> export function OrganizationCreditActivityScreen() { const { organizationId } = useOrgRole(); const query = useOrgCreditTransactions(organizationId); + const paddingBottom = useTabBarBottomPadding(); if (organizationId == null) { return null; @@ -87,7 +89,8 @@ export function OrganizationCreditActivityScreen() { data={transactions} keyExtractor={item => item.id} renderItem={({ item }) => } - contentContainerClassName="gap-3 px-6 pb-8 pt-4" + contentContainerClassName="gap-3 px-6 pt-4" + contentContainerStyle={{ paddingBottom }} ListEmptyComponent={ - @@ -122,7 +123,7 @@ export function OrganizationHubScreen() { )} - + {renameVisible && org && ( ) { export function OrganizationInvoicesScreen() { const { organizationId } = useOrgRole(); const query = useOrgInvoices(organizationId); + const paddingBottom = useTabBarBottomPadding(); if (organizationId == null) { return null; @@ -86,7 +88,8 @@ export function OrganizationInvoicesScreen() { data={invoices} keyExtractor={item => item.id} renderItem={({ item }) => } - contentContainerClassName="gap-3 px-6 pb-8 pt-4" + contentContainerClassName="gap-3 px-6 pt-4" + contentContainerStyle={{ paddingBottom }} ListEmptyComponent={ - @@ -142,7 +143,7 @@ export function OrganizationMembersScreen() { )} - + ); } 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 f955f4eb2c..62ce3cf97d 100644 --- a/apps/mobile/src/components/security-agent/analysis-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/analysis-settings-screen.tsx @@ -6,7 +6,7 @@ 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'; @@ -15,6 +15,7 @@ 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, @@ -147,7 +148,7 @@ export function AnalysisSettingsScreen({ scope }: Readonly<{ scope: string }>) { ) : undefined } /> - + Analysis depth @@ -210,7 +211,7 @@ export function AnalysisSettingsScreen({ scope }: Readonly<{ scope: string }>) { Only organization owners and billing managers can change these settings. )} - + ); } 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 ff239bc3b2..fafd3ba98e 100644 --- a/apps/mobile/src/components/security-agent/automation-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/automation-settings-screen.tsx @@ -3,7 +3,7 @@ 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'; @@ -13,6 +13,7 @@ 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, @@ -174,7 +175,7 @@ export function AutomationSettingsScreen({ scope }: Readonly<{ scope: string }>) ) : undefined } /> - + Auto Analysis @@ -264,7 +265,7 @@ export function AutomationSettingsScreen({ scope }: Readonly<{ scope: string }>) Only organization owners and billing managers can change these settings. )} - + ); } diff --git a/apps/mobile/src/components/security-agent/dashboard-screen.tsx b/apps/mobile/src/components/security-agent/dashboard-screen.tsx index 7d2ffc1323..7a602ca754 100644 --- a/apps/mobile/src/components/security-agent/dashboard-screen.tsx +++ b/apps/mobile/src/components/security-agent/dashboard-screen.tsx @@ -9,12 +9,13 @@ import { useRouter } from 'expo-router'; import * as WebBrowser from 'expo-web-browser'; import { MoreHorizontal, 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 { ScreenHeader } from '@/components/screen-header'; import { DashboardSections } from '@/components/security-agent/dashboard-sections'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { WEB_BASE_URL } from '@/lib/config'; import { useSecurityAgentConfig, @@ -130,9 +131,9 @@ export function DashboardScreen({ scope }: Readonly<{ scope: string }>) { } /> - } > @@ -211,7 +212,7 @@ export function DashboardScreen({ scope }: Readonly<{ scope: string }>) { repoFullName={repoFullName} /> ) : null} - + ); } diff --git a/apps/mobile/src/components/security-agent/finding-detail-screen.tsx b/apps/mobile/src/components/security-agent/finding-detail-screen.tsx index 6cf9c4ec08..363a15bbe0 100644 --- a/apps/mobile/src/components/security-agent/finding-detail-screen.tsx +++ b/apps/mobile/src/components/security-agent/finding-detail-screen.tsx @@ -2,7 +2,7 @@ import { canManageSecurityAgent } from '@kilocode/app-shared/security-agent'; import { useRouter } from 'expo-router'; import { Ban, ShieldOff } from 'lucide-react-native'; import { useEffect, useRef, useState } from 'react'; -import { Pressable, ScrollView, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { EmptyState } from '@/components/empty-state'; import { QueryError } from '@/components/query-error'; @@ -12,6 +12,7 @@ import { FindingDetailsPanel } from '@/components/security-agent/finding-details import { FindingRemediationPanel } from '@/components/security-agent/finding-remediation-panel'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { useSecurityAgentOrgRole, useTrackSecurityAgentInteraction, @@ -175,7 +176,7 @@ export function FindingDetailScreen({ scope, findingId }: Readonly - + {tab === 'details' && } {tab === 'analysis' && ( void analysisQuery.refetch()} /> )} - + ); } 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..2bd57372e2 100644 --- a/apps/mobile/src/components/security-agent/finding-list-screen.tsx +++ b/apps/mobile/src/components/security-agent/finding-list-screen.tsx @@ -15,6 +15,7 @@ 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'; @@ -47,6 +48,7 @@ function FindingsListFooter({ export function FindingListScreen({ scope, routeParams }: Readonly) { const colors = useThemeColors(); + const paddingBottom = useTabBarBottomPadding(); const [filters, setFilters] = useState(() => parseSecurityFindingFilters(routeParams)); const [showFilterModal, setShowFilterModal] = useState(false); const [refreshing, setRefreshing] = useState(false); @@ -125,7 +127,8 @@ export function FindingListScreen({ scope, routeParams }: Readonly )} - contentContainerClassName="gap-3 px-6 pb-24 pt-4" + contentContainerClassName="gap-3 px-6 pt-4" + contentContainerStyle={{ paddingBottom }} refreshControl={} onEndReached={() => { if (findings.hasNextPage && !findings.isFetchingNextPage) { diff --git a/apps/mobile/src/components/security-agent/notification-settings-screen.tsx b/apps/mobile/src/components/security-agent/notification-settings-screen.tsx index dafa62b8dd..9a59f87a4f 100644 --- a/apps/mobile/src/components/security-agent/notification-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/notification-settings-screen.tsx @@ -6,7 +6,7 @@ import { parseDayCount, } from '@kilocode/app-shared/security-agent'; import { useEffect, useRef, useState } from 'react'; -import { ScrollView, TextInput, View } from 'react-native'; +import { TextInput, View } from 'react-native'; import { PillGroup } from '@/components/security-agent/settings-pill-group'; import { SettingsSaveButton } from '@/components/security-agent/settings-save-button'; @@ -15,6 +15,7 @@ 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, @@ -167,9 +168,9 @@ export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string } ) : undefined } /> - @@ -251,7 +252,7 @@ export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string } Only organization owners and billing managers can change these settings. )} - + ); } diff --git a/apps/mobile/src/components/security-agent/repository-settings-screen.tsx b/apps/mobile/src/components/security-agent/repository-settings-screen.tsx index 1bca458610..95a8a0ef0e 100644 --- a/apps/mobile/src/components/security-agent/repository-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/repository-settings-screen.tsx @@ -5,13 +5,14 @@ import { import * as Haptics from 'expo-haptics'; import { Check, Lock } 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 { 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, @@ -129,7 +130,7 @@ export function RepositorySettingsScreen({ scope }: Readonly<{ scope: string }>) ) : undefined } /> - + {(['all', 'selected'] as const).map(option => ( ) Only organization owners and billing managers can change these settings. )} - + ); } diff --git a/apps/mobile/src/components/security-agent/scope-list-screen.tsx b/apps/mobile/src/components/security-agent/scope-list-screen.tsx index 8b2fde7021..c8dd3b98ba 100644 --- a/apps/mobile/src/components/security-agent/scope-list-screen.tsx +++ b/apps/mobile/src/components/security-agent/scope-list-screen.tsx @@ -2,11 +2,12 @@ import { PERSONAL_SECURITY_SCOPE } from '@kilocode/app-shared/security-agent'; import { useQuery } from '@tanstack/react-query'; import { useRouter } from 'expo-router'; import { Building2, User } 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 { Skeleton } from '@/components/ui/skeleton'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { getSecurityAgentPath } from '@/lib/security-agent'; import { useTRPC } from '@/lib/trpc'; @@ -24,7 +25,7 @@ export function ScopeListScreen() { return ( - + )) )} - + ); } 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..882a6794c1 100644 --- a/apps/mobile/src/components/security-agent/settings-overview-screen.tsx +++ b/apps/mobile/src/components/security-agent/settings-overview-screen.tsx @@ -2,13 +2,14 @@ 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 { 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 { useSecurityAgentConfig, useSecurityAgentEditCapability, @@ -99,7 +100,7 @@ export function SettingsOverviewScreen({ scope }: Readonly<{ scope: string }>) { return ( - + Security Agent @@ -180,7 +181,7 @@ export function SettingsOverviewScreen({ scope }: Readonly<{ scope: string }>) { /> )} - + ); } 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 4797e5dc76..68558d5563 100644 --- a/apps/mobile/src/components/security-agent/sla-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/sla-settings-screen.tsx @@ -5,7 +5,7 @@ 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'; @@ -13,6 +13,7 @@ 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, @@ -228,9 +229,9 @@ export function SlaSettingsScreen({ scope }: Readonly<{ scope: string }>) { ) : undefined } /> - ) { Only organization owners and billing managers can change these settings. )} - + ); } diff --git a/apps/mobile/src/components/tab-screen.tsx b/apps/mobile/src/components/tab-screen.tsx index 7e498ac998..8d9b66c680 100644 --- a/apps/mobile/src/components/tab-screen.tsx +++ b/apps/mobile/src/components/tab-screen.tsx @@ -3,6 +3,7 @@ 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; From 9698db0113d42a1001c90afbd6f437539ffdbb4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:27:11 +0200 Subject: [PATCH 024/166] fix(mobile): derive kilo-chat clearance from tab-bar layout --- .../kilo-chat/conversation-list-screen.tsx | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) 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..4f9738d529 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx @@ -4,7 +4,14 @@ 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 { + 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'; @@ -15,6 +22,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; 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,7 +55,6 @@ 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; @@ -102,21 +109,22 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { 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); From 94815236a18013da9360491d61275cd4ef67f8ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:27:18 +0200 Subject: [PATCH 025/166] fix(mobile): standardize detail-screen bottom insets --- .../(app)/(tabs)/(1_kiloclaw)/chat/instance-picker.tsx | 7 ++++++- .../src/app/(app)/kiloclaw/[instance-id]/billing.tsx | 8 +++++++- .../src/app/(app)/kiloclaw/[instance-id]/changelog.tsx | 8 +++++++- .../src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx | 6 +++--- .../(app)/kiloclaw/[instance-id]/settings/channels.tsx | 5 ++++- .../kiloclaw/[instance-id]/settings/device-pairing.tsx | 8 +++++++- .../(app)/kiloclaw/[instance-id]/settings/exec-policy.tsx | 8 +++++++- .../app/(app)/kiloclaw/[instance-id]/settings/google.tsx | 8 +++++++- .../(app)/kiloclaw/[instance-id]/settings/model-list.tsx | 6 +++--- .../app/(app)/kiloclaw/[instance-id]/settings/model.tsx | 4 +++- .../app/(app)/kiloclaw/[instance-id]/settings/secrets.tsx | 3 +++ .../(app)/kiloclaw/[instance-id]/settings/version-pin.tsx | 5 ++++- apps/mobile/src/lib/screen-insets.ts | 8 ++++++++ 13 files changed, 69 insertions(+), 15 deletions(-) create mode 100644 apps/mobile/src/lib/screen-insets.ts 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..82a73ddaf8 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 @@ -2,6 +2,7 @@ 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 { useSafeAreaInsets } from 'react-native-safe-area-context'; import { StatusBadge } from '@/components/kiloclaw/status-badge'; import { QueryError } from '@/components/query-error'; @@ -16,6 +17,7 @@ export default function InstancePickerScreen() { const router = useRouter(); const colors = useThemeColors(); const { currentId } = useLocalSearchParams<{ currentId: string }>(); + const { bottom } = useSafeAreaInsets(); const instancesQuery = useAllKiloClawInstances(); const { data: instances } = instancesQuery; @@ -30,7 +32,10 @@ export default function InstancePickerScreen() { }; return ( - + Switch Instance 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 f1a825069c..8bd07cf017 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/billing.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/billing.tsx @@ -14,6 +14,7 @@ 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 { cn } from '@/lib/utils'; function DetailRow({ @@ -98,6 +99,7 @@ export default function BillingScreen() { const instanceContext = useInstanceContext(instanceId); const isOrg = instanceContext.status === 'ready' && instanceContext.isOrg; const colors = useThemeColors(); + const paddingBottom = useDetailScreenBottomPadding(); const billingQuery = useKiloClawBillingStatus(instanceContext.status === 'ready' && !isOrg); const billing = billingQuery.data; @@ -156,7 +158,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 7ade145d51..517669fdfb 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/changelog.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/changelog.tsx @@ -12,6 +12,7 @@ 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 { useDetailScreenBottomPadding } from '@/lib/screen-insets'; export default function ChangelogScreen() { const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>(); @@ -20,6 +21,7 @@ export default function ChangelogScreen() { instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; const changelogQuery = useKiloClawChangelog(organizationId); const entries = changelogQuery.data; + const paddingBottom = useDetailScreenBottomPadding(); function renderContent() { if (changelogQuery.isPending) { @@ -69,7 +71,11 @@ export default function ChangelogScreen() { return ( - + Recent Updates 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 e591389eff..4d696085b6 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx @@ -11,7 +11,6 @@ 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 { @@ -40,11 +39,12 @@ import { } from '@/lib/hooks/use-kiloclaw-queries'; 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 instanceContext = useInstanceContext(instanceId); const organizationId = @@ -186,7 +186,7 @@ export default function DashboardScreen() { (); @@ -19,6 +20,7 @@ export default function ChannelsScreen() { instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; const catalogQuery = useKiloClawChannelCatalog(organizationId); const mutations = useKiloClawMutations(organizationId); + const paddingBottom = useDetailScreenBottomPadding(); const isLoading = catalogQuery.isPending; @@ -85,7 +87,8 @@ export default function ChannelsScreen() { = { @@ -46,6 +47,7 @@ export default function DevicePairingScreen() { const pairingQuery = useKiloClawPairing(organizationId); const devicePairingQuery = useKiloClawDevicePairing(organizationId); const mutations = useKiloClawMutations(organizationId); + const paddingBottom = useDetailScreenBottomPadding(); const isLoading = pairingQuery.isPending || devicePairingQuery.isPending; @@ -171,7 +173,11 @@ export default function DevicePairingScreen() { return ( - + {channelRequests.length > 0 && ( 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 6d9b719deb..a3e5b2bcd7 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 @@ -11,6 +11,7 @@ import { Text } from '@/components/ui/text'; import { useInstanceContext } from '@/lib/hooks/use-instance-context'; import { useKiloClawMutations, useKiloClawStatus } from '@/lib/hooks/use-kiloclaw-queries'; import { type ExecPreset, execPresetToConfig } from '@/lib/onboarding'; +import { useDetailScreenBottomPadding } from '@/lib/screen-insets'; import { cn } from '@/lib/utils'; type PolicyOption = { @@ -58,6 +59,7 @@ export default function ExecPolicyScreen() { instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; const statusQuery = useKiloClawStatus(organizationId); const mutations = useKiloClawMutations(organizationId); + const paddingBottom = useDetailScreenBottomPadding(); const currentPreset = resolvePreset(statusQuery.data?.execSecurity, statusQuery.data?.execAsk); @@ -116,7 +118,11 @@ export default function ExecPolicyScreen() { return ( - + {POLICY_OPTIONS.map(option => { const Icon = option.icon; 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 d09ae76f66..aa6fd04a7d 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 @@ -18,6 +18,7 @@ import { useKiloClawMutations, useKiloClawStatus, } from '@/lib/hooks/use-kiloclaw-queries'; +import { useDetailScreenBottomPadding } from '@/lib/screen-insets'; import { cn } from '@/lib/utils'; export default function GoogleScreen() { @@ -27,6 +28,7 @@ export default function GoogleScreen() { instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; const statusQuery = useKiloClawStatus(organizationId); const mutations = useKiloClawMutations(organizationId); + const paddingBottom = useDetailScreenBottomPadding(); const [copied, setCopied] = useState(false); @@ -109,7 +111,11 @@ export default function GoogleScreen() { return ( - + {/* Connection status card */} 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 377445852c..80122899c5 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 @@ -2,7 +2,6 @@ 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 { useLocalSearchParams, useRouter } from 'expo-router'; import { InstanceContextBoundary } from '@/components/kiloclaw/instance-context-boundary'; @@ -12,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { 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'; @@ -30,7 +30,7 @@ export default function ModelListScreen() { instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; const router = useRouter(); const colors = useThemeColors(); - const { bottom } = useSafeAreaInsets(); + const paddingBottom = useDetailScreenBottomPadding(); const trpc = useTRPC(); const [searchFilter, setSearchFilter] = useState(''); @@ -159,7 +159,7 @@ 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={{ paddingBottom }} 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 371bac8d71..d3ed491d6c 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 @@ -5,10 +5,12 @@ import { InstanceContextBoundary } from '@/components/kiloclaw/instance-context- 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 ( @@ -22,7 +24,7 @@ export default function ModelSettingsScreen() { 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 57c9a9b742..cb9d7e6580 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 @@ -11,6 +11,7 @@ import { ScreenHeader } from '@/components/screen-header'; import { Skeleton } from '@/components/ui/skeleton'; import { 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 }>(); @@ -19,6 +20,7 @@ export default function SecretsScreen() { instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; const mutations = useKiloClawMutations(organizationId); const catalogQuery = useKiloClawSecretCatalog(organizationId); + const paddingBottom = useDetailScreenBottomPadding(); const isLoading = catalogQuery.isPending; function renderContent() { @@ -86,6 +88,7 @@ export default function SecretsScreen() { (); const flatListRef = useRef>(null); @@ -221,7 +223,8 @@ export default function VersionPinScreen() { data={versions} keyExtractor={item => 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" 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; +} From dbb47a939e8a2e6a08c3cd204dad1c6be977bc9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:30:10 +0200 Subject: [PATCH 026/166] fix(mobile): reliable FormField error announcement --- apps/mobile/src/components/ui/form-field.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/components/ui/form-field.tsx b/apps/mobile/src/components/ui/form-field.tsx index ffcab11c7e..36644293ab 100644 --- a/apps/mobile/src/components/ui/form-field.tsx +++ b/apps/mobile/src/components/ui/form-field.tsx @@ -24,10 +24,10 @@ function FormField({ label, error, disabled, className, ref, ...props }: Readonl {label} - {error ? {error} : null} + {error ? ( + + {error} + + ) : null} ); } From 6b3fccd6eb22f55240ecc177bb8adbd26fbd533a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:32:57 +0200 Subject: [PATCH 027/166] refactor(mobile): extract shared form-sheet detent options --- .../(tabs)/(3_profile)/organization/_layout.tsx | 13 +++---------- .../security-agent/[scope]/_layout.tsx | 12 ++---------- apps/mobile/src/app/(app)/_layout.tsx | 10 ++-------- apps/mobile/src/lib/form-sheet.ts | 15 +++++++++++++++ 4 files changed, 22 insertions(+), 28 deletions(-) create mode 100644 apps/mobile/src/lib/form-sheet.ts 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 8fb8e9147f..711c9bd9dd 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,9 +1,8 @@ import { type Href, Stack, useLocalSearchParams } from 'expo-router'; -import { Platform, StatusBar, useWindowDimensions } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; 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, @@ -15,14 +14,7 @@ import { parseParam } from '@/lib/route-params'; export default function SecurityAgentScopeLayout() { const { scope: rawScope } = useLocalSearchParams<{ scope: string }>(); const scope = parseParam(rawScope); - 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 { fullSheetDetent } = useFormSheetDetents(); if (!scope) { return ; diff --git a/apps/mobile/src/app/(app)/_layout.tsx b/apps/mobile/src/app/(app)/_layout.tsx index 5355ea6dc9..530e8d5030 100644 --- a/apps/mobile/src/app/(app)/_layout.tsx +++ b/apps/mobile/src/app/(app)/_layout.tsx @@ -1,21 +1,15 @@ import { Stack } from 'expo-router'; -import { Platform, StatusBar, useWindowDimensions } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { UserWebConnectionProvider } from '@/components/agents/user-web-connection-provider'; import { KiloChatPresenceMount } from '@/components/kilo-chat/kilo-chat-presence-mount'; import { KiloChatProvider } from '@/components/kilo-chat/kilo-chat-provider'; +import { useFormSheetDetents } from '@/lib/form-sheet'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { StoreKiloPassPurchaseProvider } from '@/lib/kilo-pass/use-store-kilo-pass-purchase'; export default function AppLayout() { const colors = useThemeColors(); - 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(); return ( 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 }; +} From bff6b06aa5ed4f6d4538f21baf14df2e48a33176 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:33:18 +0200 Subject: [PATCH 028/166] fix(mobile): cancel infinite spin animations on unmount --- apps/mobile/src/components/ui/skeleton.tsx | 3 +++ apps/mobile/src/components/ui/spinning-icon.tsx | 2 ++ 2 files changed, 5 insertions(+) diff --git a/apps/mobile/src/components/ui/skeleton.tsx b/apps/mobile/src/components/ui/skeleton.tsx index 118b32f6a0..2d5370f5b0 100644 --- a/apps/mobile/src/components/ui/skeleton.tsx +++ b/apps/mobile/src/components/ui/skeleton.tsx @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import Animated, { + cancelAnimation, useAnimatedStyle, useReducedMotion, useSharedValue, @@ -22,6 +23,8 @@ export function Skeleton({ className }: Readonly) { return; } opacity.value = withRepeat(withTiming(1, { duration: 1000 }), -1, true); + + return () => cancelAnimation(opacity); }, [opacity, reducedMotion]); const animatedStyle = useAnimatedStyle(() => ({ diff --git a/apps/mobile/src/components/ui/spinning-icon.tsx b/apps/mobile/src/components/ui/spinning-icon.tsx index 809426df8f..515f103a89 100644 --- a/apps/mobile/src/components/ui/spinning-icon.tsx +++ b/apps/mobile/src/components/ui/spinning-icon.tsx @@ -36,6 +36,8 @@ export function SpinningIcon({ cancelAnimation(rotation); rotation.value = 0; } + + return () => cancelAnimation(rotation); }, [isAnimating, rotation]); const animatedStyle = useAnimatedStyle(() => ({ From c21bc3e99311f194948ed63f4e0900e170c7edaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:34:01 +0200 Subject: [PATCH 029/166] feat(mobile): shared choice-row primitive --- .../components/code-reviewer/option-list.tsx | 28 +++------ apps/mobile/src/components/ui/choice-row.tsx | 62 +++++++++++++++++++ 2 files changed, 70 insertions(+), 20 deletions(-) create mode 100644 apps/mobile/src/components/ui/choice-row.tsx diff --git a/apps/mobile/src/components/code-reviewer/option-list.tsx b/apps/mobile/src/components/code-reviewer/option-list.tsx index 9243c6599c..c0c40320c2 100644 --- a/apps/mobile/src/components/code-reviewer/option-list.tsx +++ b/apps/mobile/src/components/code-reviewer/option-list.tsx @@ -1,12 +1,9 @@ -import * as Haptics from 'expo-haptics'; import { useRouter } from 'expo-router'; -import { Check } from 'lucide-react-native'; -import { Pressable, View } from 'react-native'; +import { View } from 'react-native'; import { ScreenHeader } from '@/components/screen-header'; -import { Text } from '@/components/ui/text'; import { TabScreenScrollView } from '@/components/tab-screen'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { ChoiceRow } from '@/components/ui/choice-row'; type OptionListProps = { title: string; @@ -26,32 +23,23 @@ export function OptionList({ descriptions, }: Readonly>) { const router = useRouter(); - const colors = useThemeColors(); return ( {options.map(option => ( - { - void Haptics.selectionAsync(); onSelect(option); router.back(); }} - > - - {option} - {descriptions?.[option] ? ( - - {descriptions[option]} - - ) : null} - - - + className="border-b-[0.5px] border-hair-soft" + /> ))} 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..4f8fe9369e --- /dev/null +++ b/apps/mobile/src/components/ui/choice-row.tsx @@ -0,0 +1,62 @@ +import * as Haptics from 'expo-haptics'; +import { Check } from 'lucide-react-native'; +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 = { + label: string; + description?: string; + selected: boolean; + onPress: () => void; + disabled?: boolean; + /** Renders `accessibilityRole="checkbox"` instead of `"radio"`. */ + multi?: boolean; + /** Extra classes on the row container, e.g. a divider border. */ + className?: string; +}; + +/** + * 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, + multi, + className, +}: Readonly) { + const colors = useThemeColors(); + + return ( + { + void Haptics.selectionAsync(); + onPress(); + }} + accessibilityRole={multi ? 'checkbox' : 'radio'} + accessibilityState={{ checked: selected, disabled: Boolean(disabled) }} + > + + {label} + {description ? ( + + {description} + + ) : null} + + + + ); +} From fefbcf4a1c78da00a1f1fbab2274f6de82fe80b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:34:47 +0200 Subject: [PATCH 030/166] feat(mobile): add placement variants to EmptyState and QueryError --- apps/mobile/src/components/empty-state.tsx | 10 +++++++++- apps/mobile/src/components/query-error.tsx | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/empty-state.tsx b/apps/mobile/src/components/empty-state.tsx index 1530a3ca7a..000fd56e44 100644 --- a/apps/mobile/src/components/empty-state.tsx +++ b/apps/mobile/src/components/empty-state.tsx @@ -12,6 +12,7 @@ type EmptyStateProps = { description: string; className?: string; action?: ReactNode; + placement?: 'center' | 'top'; }; export function EmptyState({ @@ -20,11 +21,18 @@ export function EmptyState({ description, className, action, + placement = 'center', }: Readonly) { const colors = useThemeColors(); return ( - + diff --git a/apps/mobile/src/components/query-error.tsx b/apps/mobile/src/components/query-error.tsx index f652a406e5..8d0e1aeddf 100644 --- a/apps/mobile/src/components/query-error.tsx +++ b/apps/mobile/src/components/query-error.tsx @@ -10,17 +10,25 @@ type QueryErrorProps = { message?: string; onRetry?: () => void; className?: string; + placement?: 'center' | 'top'; }; export function QueryError({ message = 'Something went wrong', onRetry, className, + placement = 'center', }: Readonly) { const colors = useThemeColors(); return ( - + From ecde156c4ac6b815626444220b62cef410301994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:35:19 +0200 Subject: [PATCH 031/166] fix(mobile): lint-clean animation cleanup returns --- apps/mobile/src/components/ui/skeleton.tsx | 9 +++++---- apps/mobile/src/components/ui/spinning-icon.tsx | 4 +++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/components/ui/skeleton.tsx b/apps/mobile/src/components/ui/skeleton.tsx index 2d5370f5b0..adb93f8dd3 100644 --- a/apps/mobile/src/components/ui/skeleton.tsx +++ b/apps/mobile/src/components/ui/skeleton.tsx @@ -19,12 +19,13 @@ export function Skeleton({ className }: Readonly) { const opacity = useSharedValue(0.4); useEffect(() => { - if (reducedMotion) { - return; + if (!reducedMotion) { + opacity.value = withRepeat(withTiming(1, { duration: 1000 }), -1, true); } - opacity.value = withRepeat(withTiming(1, { duration: 1000 }), -1, true); - return () => cancelAnimation(opacity); + return () => { + cancelAnimation(opacity); + }; }, [opacity, reducedMotion]); const animatedStyle = useAnimatedStyle(() => ({ diff --git a/apps/mobile/src/components/ui/spinning-icon.tsx b/apps/mobile/src/components/ui/spinning-icon.tsx index 515f103a89..4eadb74629 100644 --- a/apps/mobile/src/components/ui/spinning-icon.tsx +++ b/apps/mobile/src/components/ui/spinning-icon.tsx @@ -37,7 +37,9 @@ export function SpinningIcon({ rotation.value = 0; } - return () => cancelAnimation(rotation); + return () => { + cancelAnimation(rotation); + }; }, [isAnimating, rotation]); const animatedStyle = useAnimatedStyle(() => ({ From 6590120214e5a2336c13a4563227eae249b5acf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:36:19 +0200 Subject: [PATCH 032/166] refactor(mobile): shared SheetHeader for picker sheets --- .../(1_kiloclaw)/chat/instance-picker.tsx | 25 ++++------- apps/mobile/src/app/(app)/_layout.tsx | 3 +- .../src/app/(app)/agent-chat/mode-picker.tsx | 41 +++++++++++++------ .../src/app/(app)/agent-chat/repo-picker.tsx | 18 ++------ .../agents/model-picker-content.tsx | 20 +++------ apps/mobile/src/components/sheet-header.tsx | 24 +++++++++++ 6 files changed, 71 insertions(+), 60 deletions(-) create mode 100644 apps/mobile/src/components/sheet-header.tsx 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 82a73ddaf8..fc22da3102 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 @@ -6,6 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { StatusBadge } from '@/components/kiloclaw/status-badge'; import { QueryError } from '@/components/query-error'; +import { SheetHeader } from '@/components/sheet-header'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; @@ -36,22 +37,12 @@ export default function InstancePickerScreen() { className="flex-1 bg-background" contentContainerStyle={{ paddingBottom: bottom + 16 }} > - - - 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 - - - + { + router.back(); + }} + /> {instancesQuery.isPending ? ( @@ -63,7 +54,7 @@ export default function InstancePickerScreen() { {instancesQuery.isError ? ( { void instancesQuery.refetch(); }} diff --git a/apps/mobile/src/app/(app)/_layout.tsx b/apps/mobile/src/app/(app)/_layout.tsx index 530e8d5030..20ec428c07 100644 --- a/apps/mobile/src/app/(app)/_layout.tsx +++ b/apps/mobile/src/app/(app)/_layout.tsx @@ -51,8 +51,7 @@ export default function AppLayout() { presentation: 'formSheet', sheetAllowedDetents: [0.5], sheetGrabberVisible: true, - headerShown: true, - title: 'Select Mode', + headerShown: false, }} /> - - No options available - + + { + router.back(); + }} + /> + + + No options available + + ); } @@ -68,13 +77,21 @@ export default function ModePickerScreen() { } return ( - item.value} - renderItem={renderItem} - contentInsetAdjustmentBehavior="automatic" - ItemSeparatorComponent={() => } - /> + + { + router.back(); + }} + /> + item.value} + renderItem={renderItem} + contentInsetAdjustmentBehavior="automatic" + ItemSeparatorComponent={() => } + /> + ); } 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..1b7869d8e5 100644 --- a/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx @@ -5,6 +5,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { FlatList, Pressable, TextInput, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { SheetHeader } from '@/components/sheet-header'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { clearRepoPickerBridge, getRepoPickerBridge } from '@/lib/picker-bridge'; @@ -70,20 +71,9 @@ export default function RepoPickerScreen() { keyboardDismissMode="on-drag" contentContainerStyle={{ paddingBottom: bottom }} ListHeaderComponent={ - - - Select Repository - - Done - - - + + + - - Select Model - - Done - - - + + + void }) { + return ( + + + + {title} + + + Done + + + + ); +} From 658c12d2534b3bea51ed2f074408519e6ceff516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:37:10 +0200 Subject: [PATCH 033/166] fix(mobile): webhook retry dismissal order and shared route-param validation --- .../[scope]/[platform]/focus-areas.tsx | 21 ++++--------- .../code-reviewer/[scope]/[platform]/gate.tsx | 21 ++++--------- .../[scope]/[platform]/index.tsx | 16 ++++------ .../[scope]/[platform]/instructions.tsx | 17 ++++------- .../[scope]/[platform]/repos.tsx | 21 ++++--------- .../[scope]/[platform]/style.tsx | 21 ++++--------- .../platform-overview-screen.tsx | 14 +++++---- .../src/lib/code-reviewer-config.test.ts | 30 +++++++++++++++++++ .../mobile/src/lib/hooks/use-code-reviewer.ts | 17 +++++------ .../lib/hooks/use-reviewer-route-params.ts | 27 +++++++++++++++++ 10 files changed, 107 insertions(+), 98 deletions(-) create mode 100644 apps/mobile/src/lib/code-reviewer-config.test.ts create mode 100644 apps/mobile/src/lib/hooks/use-reviewer-route-params.ts 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]/focus-areas.tsx index 17ed8ed689..e983c2b128 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]/focus-areas.tsx @@ -1,5 +1,5 @@ import * as Haptics from 'expo-haptics'; -import { type Href, useLocalSearchParams } from 'expo-router'; +import { type Href } from 'expo-router'; import { Check } from 'lucide-react-native'; import { Pressable, View } from 'react-native'; @@ -7,32 +7,23 @@ import { InvalidRouteState } from '@/components/invalid-route-state'; import { ScreenHeader } from '@/components/screen-header'; import { Text } from '@/components/ui/text'; import { TabScreenScrollView } from '@/components/tab-screen'; -import { - parseReviewerPlatform, - REVIEW_FOCUS_AREAS, - type ReviewerPlatform, -} from '@/lib/code-reviewer-config'; +import { REVIEW_FOCUS_AREAS, type ReviewerPlatform } from '@/lib/code-reviewer-config'; import { useReviewConfig, useReviewConfigCacheReader, useSaveReviewConfig, } from '@/lib/hooks/use-code-reviewer'; +import { useValidatedReviewerRouteParams } from '@/lib/hooks/use-reviewer-route-params'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { parseParam } from '@/lib/route-params'; export default function FocusAreasRoute() { - const { scope: rawScope, platform: rawPlatform } = useLocalSearchParams<{ - scope: string; - platform: string; - }>(); - const scope = parseParam(rawScope); - const platform = scope ? parseReviewerPlatform(scope, rawPlatform) : null; + const params = useValidatedReviewerRouteParams(); - if (!scope || !platform) { + if (!params) { return ; } - return ; + return ; } function FocusAreasRouteContent({ 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]/gate.tsx index 393bdfcb3b..1924bd6c90 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]/gate.tsx @@ -1,14 +1,10 @@ -import { type Href, useLocalSearchParams } from 'expo-router'; +import { type Href } from 'expo-router'; import { OptionList } from '@/components/code-reviewer/option-list'; import { InvalidRouteState } from '@/components/invalid-route-state'; -import { - GATE_THRESHOLDS, - parseReviewerPlatform, - type ReviewerPlatform, -} from '@/lib/code-reviewer-config'; +import { GATE_THRESHOLDS, type ReviewerPlatform } from '@/lib/code-reviewer-config'; import { useReviewConfig, useSaveReviewConfig } from '@/lib/hooks/use-code-reviewer'; -import { parseParam } from '@/lib/route-params'; +import { useValidatedReviewerRouteParams } from '@/lib/hooks/use-reviewer-route-params'; const DESCRIPTIONS = { off: 'Never fail the PR check', @@ -18,18 +14,13 @@ const DESCRIPTIONS = { } as const; export default function GateThresholdRoute() { - const { scope: rawScope, platform: rawPlatform } = useLocalSearchParams<{ - scope: string; - platform: string; - }>(); - const scope = parseParam(rawScope); - const platform = scope ? parseReviewerPlatform(scope, rawPlatform) : null; + const params = useValidatedReviewerRouteParams(); - if (!scope || !platform) { + if (!params) { return ; } - return ; + return ; } function GateThresholdRouteContent({ 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 83c6c5eff0..4b9b7cba73 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,21 +1,15 @@ -import { type Href, useLocalSearchParams } from 'expo-router'; +import { type Href } from 'expo-router'; import { PlatformOverviewScreen } from '@/components/code-reviewer/platform-overview-screen'; import { InvalidRouteState } from '@/components/invalid-route-state'; -import { parseReviewerPlatform } from '@/lib/code-reviewer-config'; -import { parseParam } from '@/lib/route-params'; +import { useValidatedReviewerRouteParams } from '@/lib/hooks/use-reviewer-route-params'; export default function CodeReviewerPlatformRoute() { - const { scope: rawScope, platform: rawPlatform } = useLocalSearchParams<{ - scope: string; - platform: string; - }>(); - const scope = parseParam(rawScope); - const platform = scope ? parseReviewerPlatform(scope, rawPlatform) : null; + const params = useValidatedReviewerRouteParams(); - if (!scope || !platform) { + if (!params) { return ; } - return ; + return ; } 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]/instructions.tsx index f9c8ba477c..16016f493a 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]/instructions.tsx @@ -1,4 +1,4 @@ -import { type Href, useLocalSearchParams, useRouter } from 'expo-router'; +import { type Href, useRouter } from 'expo-router'; import { useRef } from 'react'; import { TextInput, View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; @@ -9,9 +9,9 @@ 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 { parseReviewerPlatform, type ReviewerPlatform } from '@/lib/code-reviewer-config'; +import { type ReviewerPlatform } from '@/lib/code-reviewer-config'; import { useReviewConfig, useSaveReviewConfig } from '@/lib/hooks/use-code-reviewer'; -import { parseParam } from '@/lib/route-params'; +import { useValidatedReviewerRouteParams } from '@/lib/hooks/use-reviewer-route-params'; // Mounted only once `data != null`, so useRef(initial) captures the real // loaded value instead of the pre-fetch default. @@ -56,18 +56,13 @@ function InstructionsEditor({ } export default function InstructionsRoute() { - const { scope: rawScope, platform: rawPlatform } = useLocalSearchParams<{ - scope: string; - platform: string; - }>(); - const scope = parseParam(rawScope); - const platform = scope ? parseReviewerPlatform(scope, rawPlatform) : null; + const params = useValidatedReviewerRouteParams(); - if (!scope || !platform) { + if (!params) { return ; } - return ; + return ; } function InstructionsRouteContent({ 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 index 1c2d78e75c..42e45f403b 100644 --- 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 @@ -1,5 +1,5 @@ import * as Haptics from 'expo-haptics'; -import { type Href, useLocalSearchParams } from 'expo-router'; +import { type Href } from 'expo-router'; import { Check, Lock } from 'lucide-react-native'; import { Pressable, View } from 'react-native'; @@ -8,11 +8,7 @@ import { ScreenHeader } from '@/components/screen-header'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { TabScreenScrollView } from '@/components/tab-screen'; -import { - parseReviewerPlatform, - PLATFORM_CAPABILITIES, - type ReviewerPlatform, -} from '@/lib/code-reviewer-config'; +import { PLATFORM_CAPABILITIES, type ReviewerPlatform } from '@/lib/code-reviewer-config'; import { useBitbucketReadiness, useGitHubRepositories, @@ -21,22 +17,17 @@ import { useReviewConfigCacheReader, useSaveReviewConfig, } from '@/lib/hooks/use-code-reviewer'; +import { useValidatedReviewerRouteParams } from '@/lib/hooks/use-reviewer-route-params'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { parseParam } from '@/lib/route-params'; export default function ReposRoute() { - const { scope: rawScope, platform: rawPlatform } = useLocalSearchParams<{ - scope: string; - platform: string; - }>(); - const scope = parseParam(rawScope); - const platform = scope ? parseReviewerPlatform(scope, rawPlatform) : null; + const params = useValidatedReviewerRouteParams(); - if (!scope || !platform) { + if (!params) { return ; } - return ; + return ; } function ReposRouteContent({ 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]/style.tsx index 0f121abe9e..47bdaa7918 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]/style.tsx @@ -1,14 +1,10 @@ -import { type Href, useLocalSearchParams } from 'expo-router'; +import { type Href } from 'expo-router'; import { OptionList } from '@/components/code-reviewer/option-list'; import { InvalidRouteState } from '@/components/invalid-route-state'; -import { - parseReviewerPlatform, - REVIEW_STYLES, - type ReviewerPlatform, -} from '@/lib/code-reviewer-config'; +import { REVIEW_STYLES, type ReviewerPlatform } from '@/lib/code-reviewer-config'; import { useReviewConfig, useSaveReviewConfig } from '@/lib/hooks/use-code-reviewer'; -import { parseParam } from '@/lib/route-params'; +import { useValidatedReviewerRouteParams } from '@/lib/hooks/use-reviewer-route-params'; const DESCRIPTIONS = { strict: 'Flag everything, hold a high bar', @@ -18,18 +14,13 @@ const DESCRIPTIONS = { } as const; export default function ReviewStyleRoute() { - const { scope: rawScope, platform: rawPlatform } = useLocalSearchParams<{ - scope: string; - platform: string; - }>(); - const scope = parseParam(rawScope); - const platform = scope ? parseReviewerPlatform(scope, rawPlatform) : null; + const params = useValidatedReviewerRouteParams(); - if (!scope || !platform) { + if (!params) { return ; } - return ; + return ; } function ReviewStyleRouteContent({ 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 556eccef58..986a526be4 100644 --- a/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx @@ -8,7 +8,7 @@ import { ScrollText, ShieldCheck, } from 'lucide-react-native'; -import { 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'; @@ -32,6 +32,7 @@ import { useSaveReviewConfig, useToggleReviewer, } from '@/lib/hooks/use-code-reviewer'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1); @@ -40,16 +41,14 @@ export function PlatformOverviewScreen({ platform, }: Readonly<{ scope: string; platform: ReviewerPlatform }>) { const router = useRouter(); + const colors = useThemeColors(); 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 { hasWebhookSyncWarning, dismissWebhookSyncWarning } = useGitLabWebhookWarning( - scope, - platform - ); + const { hasWebhookSyncWarning } = useGitLabWebhookWarning(scope, platform); const { models, isLoading: modelsLoading } = useAvailableModels( scope === PERSONAL_SCOPE ? undefined : scope ); @@ -199,12 +198,15 @@ export function PlatformOverviewScreen({ 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/hooks/use-code-reviewer.ts b/apps/mobile/src/lib/hooks/use-code-reviewer.ts index a341da2396..eb74c1a0ae 100644 --- a/apps/mobile/src/lib/hooks/use-code-reviewer.ts +++ b/apps/mobile/src/lib/hooks/use-code-reviewer.ts @@ -194,12 +194,14 @@ function gitLabWebhookWarningQueryKey(scope: string, platform: ReviewerPlatform) * 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 until the caller dismisses it (e.g. - * after the user retries webhook setup). + * 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 queryClient = useQueryClient(); const queryKey = gitLabWebhookWarningQueryKey(scope, platform); const { data } = useQuery({ queryKey, @@ -208,12 +210,7 @@ export function useGitLabWebhookWarning(scope: string, platform: ReviewerPlatfor staleTime: Infinity, }); - return { - hasWebhookSyncWarning: data, - dismissWebhookSyncWarning: () => { - queryClient.setQueryData(queryKey, false); - }, - }; + return { hasWebhookSyncWarning: data }; } export function useSaveReviewConfig(scope: string, platform: ReviewerPlatform) { 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 }; +} From 5a1b92e41d1b0bbbefe410206d1b6fe79bf78e2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:38:57 +0200 Subject: [PATCH 034/166] refactor(mobile): shared PickerSheet shell --- .../(1_kiloclaw)/chat/instance-picker.tsx | 23 ++-- .../src/app/(app)/agent-chat/repo-picker.tsx | 100 ++++++++++-------- .../agents/model-picker-content.tsx | 98 +++++++++-------- apps/mobile/src/components/picker-sheet.tsx | 39 +++++++ 4 files changed, 152 insertions(+), 108 deletions(-) create mode 100644 apps/mobile/src/components/picker-sheet.tsx 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 fc22da3102..9b90f67a64 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,12 +1,11 @@ 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 { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Pressable, View } from 'react-native'; import { StatusBadge } from '@/components/kiloclaw/status-badge'; +import { PickerSheet } from '@/components/picker-sheet'; import { QueryError } from '@/components/query-error'; -import { SheetHeader } from '@/components/sheet-header'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; @@ -18,7 +17,6 @@ export default function InstancePickerScreen() { const router = useRouter(); const colors = useThemeColors(); const { currentId } = useLocalSearchParams<{ currentId: string }>(); - const { bottom } = useSafeAreaInsets(); const instancesQuery = useAllKiloClawInstances(); const { data: instances } = instancesQuery; @@ -33,17 +31,12 @@ export default function InstancePickerScreen() { }; return ( - { + router.back(); + }} > - { - router.back(); - }} - /> - {instancesQuery.isPending ? ( @@ -90,6 +83,6 @@ export default function InstancePickerScreen() { ); }) : 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 1b7869d8e5..7c2f282f3e 100644 --- a/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx @@ -5,7 +5,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { FlatList, Pressable, TextInput, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { SheetHeader } from '@/components/sheet-header'; +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'; @@ -56,24 +56,30 @@ export default function RepoPickerScreen() { if (!bridge) { return ( - - No repositories available - + + No repositories available + + } + /> ); } return ( - repo.fullName} - keyboardShouldPersistTaps="handled" - keyboardDismissMode="on-drag" - contentContainerStyle={{ paddingBottom: bottom }} - ListHeaderComponent={ - - - + + 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={ + + + {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} + + )} + /> + ); } diff --git a/apps/mobile/src/components/agents/model-picker-content.tsx b/apps/mobile/src/components/agents/model-picker-content.tsx index ffcb7fbc54..fc88257b74 100644 --- a/apps/mobile/src/components/agents/model-picker-content.tsx +++ b/apps/mobile/src/components/agents/model-picker-content.tsx @@ -6,7 +6,7 @@ import { FlatList, TextInput, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ModelPickerOptionRow } from '@/components/agents/model-selector'; -import { SheetHeader } from '@/components/sheet-header'; +import { PickerSheet } from '@/components/picker-sheet'; import { Text } from '@/components/ui/text'; import { useModelPreferences } from '@/lib/hooks/use-model-preferences'; import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; @@ -134,24 +134,30 @@ export function ModelPickerContent() { if (!bridge) { return ( - - No models available - + + No models available + + } + /> ); } return ( - item.key} - keyboardShouldPersistTaps="handled" - keyboardDismissMode="on-drag" - contentContainerStyle={{ paddingBottom: bottom }} - ListHeaderComponent={ - - - + + item.key} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" + contentContainerStyle={{ paddingBottom: bottom }} + ListHeaderComponent={ + - - } - ListEmptyComponent={ - - - {search.trim() ? 'No models match your search' : 'No models available'} - - - } - renderItem={({ item }) => { - if (item.type === 'header') { - return ( - - - {item.title} - - - ); } + ListEmptyComponent={ + + + {search.trim() ? 'No models match your search' : 'No models available'} + + + } + renderItem={({ item }) => { + if (item.type === 'header') { + return ( + + + {item.title} + + + ); + } - return ( - - ); - }} - /> + return ( + + ); + }} + /> + ); } diff --git a/apps/mobile/src/components/picker-sheet.tsx b/apps/mobile/src/components/picker-sheet.tsx new file mode 100644 index 0000000000..89f1ee0927 --- /dev/null +++ b/apps/mobile/src/components/picker-sheet.tsx @@ -0,0 +1,39 @@ +import { type ReactNode } from 'react'; +import { ScrollView, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { SheetHeader } from '@/components/sheet-header'; + +export function PickerSheet({ + title, + onDone, + children, + fallback, + scrollable = true, +}: { + title: string; + onDone: () => void; + children?: ReactNode; + /** Rendered instead of children when the caller's data source is gone. */ + fallback?: ReactNode; + /** + * 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 = fallback ?? children; + + return ( + + + {scrollable ? ( + {body} + ) : ( + body + )} + + ); +} From e0a35690e0514557b4b17fd737a58097b1700cfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:40:23 +0200 Subject: [PATCH 035/166] feat(mobile): QueryError variants; replace ad-hoc error rows --- .../src/app/(app)/agent-chat/[session-id].tsx | 21 +++--- .../code-reviewer/review-detail-screen.tsx | 35 +++++----- .../code-reviewer/review-list-screen.tsx | 31 ++++----- apps/mobile/src/components/profile-screen.tsx | 19 +++--- apps/mobile/src/components/query-error.tsx | 65 +++++++++++++++++-- .../security-agent/dashboard-screen.tsx | 17 ++--- .../security-agent/scope-entry-screen.tsx | 16 +---- 7 files changed, 114 insertions(+), 90 deletions(-) diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx index 9cab025a15..29398fe603 100644 --- a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx +++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx @@ -1,12 +1,12 @@ import { type KiloSessionId } from 'cloud-agent-sdk'; import { useLocalSearchParams } from 'expo-router'; import { useQuery } from '@tanstack/react-query'; -import { ActivityIndicator, Pressable, View } from 'react-native'; +import { ActivityIndicator, View } from 'react-native'; import { AgentSessionProvider } from '@/components/agents/session-provider'; import { SessionDetailContent } from '@/components/agents/session-detail-content'; +import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; -import { Text } from '@/components/ui/text'; import { useTRPC } from '@/lib/trpc'; export default function SessionDetailScreen() { @@ -42,17 +42,12 @@ export default function SessionDetailScreen() { return ( - - - Failed to load session details - - void sessionQuery.refetch()} - > - Retry - - + void sessionQuery.refetch()} + /> ); } 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 b3d7024fe5..4e111a6832 100644 --- a/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx @@ -1,5 +1,5 @@ import * as Haptics from 'expo-haptics'; -import { Alert, Linking, Pressable, View } from 'react-native'; +import { Alert, Linking, View } from 'react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { @@ -7,6 +7,7 @@ import { isRetriggerableReviewStatus, } from '@kilocode/app-shared/code-review'; 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'; @@ -48,7 +49,7 @@ export function ReviewDetailScreen({ scope, reviewId, }: Readonly<{ scope: string; reviewId: string }>) { - const { data, isLoading, isError, error, refetch } = useReviewDetail(reviewId); + const { data, isLoading, isError, refetch } = useReviewDetail(reviewId); const cancelReview = useCancelReview(scope); const retriggerReview = useRetriggerReview(scope); @@ -56,15 +57,12 @@ export function ReviewDetailScreen({ return ( - - { - void refetch(); - }} - > - {error.message}. Tap to retry. - + + void refetch()} + /> ); @@ -88,15 +86,12 @@ export function ReviewDetailScreen({ return ( - - { - void refetch(); - }} - > - {data.error}. Tap to retry. - + + void refetch()} + /> ); 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 0096c7e4e5..f73d829083 100644 --- a/apps/mobile/src/components/code-reviewer/review-list-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/review-list-screen.tsx @@ -9,6 +9,7 @@ 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 { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; @@ -44,7 +45,7 @@ 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, refetch } = useReviewList(scope); return ( @@ -63,25 +64,21 @@ 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()} + /> )} {!isLoading && data && !data.success && ( - { - void refetch(); - }} - > - {data.error}. Tap to retry. - + void refetch()} + /> )} {!isLoading && data?.success && data.reviews.length === 0 && ( diff --git a/apps/mobile/src/components/profile-screen.tsx b/apps/mobile/src/components/profile-screen.tsx index 8f4a961680..f6aa7ee3d0 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'; @@ -191,16 +192,12 @@ export function ProfileScreen() { )} {providersError && ( - { - void refetchProviders(); - }} - > - - Failed to load accounts. Tap to retry. - - + void refetchProviders()} + /> )} {data?.providers.map(p => { diff --git a/apps/mobile/src/components/query-error.tsx b/apps/mobile/src/components/query-error.tsx index 8d0e1aeddf..8c765b5a19 100644 --- a/apps/mobile/src/components/query-error.tsx +++ b/apps/mobile/src/components/query-error.tsx @@ -1,4 +1,11 @@ -import { WifiOff } from 'lucide-react-native'; +import { + AlertCircle, + Lock, + type LucideIcon, + SearchX, + ServerCrash, + WifiOff, +} from 'lucide-react-native'; import { View } from 'react-native'; import { Button } from '@/components/ui/button'; @@ -6,20 +13,64 @@ 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; + /** Legacy description prop, kept for existing call sites — prefer `description`. */ message?: string; + description?: string; onRetry?: () => void; + isRetrying?: boolean; className?: string; placement?: 'center' | 'top'; }; export function QueryError({ - message = 'Something went wrong', + variant = 'offline', + title, + message, + description, onRetry, + isRetrying = false, className, placement = 'center', }: Readonly) { const colors = useThemeColors(); + const meta = VARIANT_META[variant]; + const Icon = meta.icon; return ( - + - Failed to load + + {title ?? meta.title} + - {message} + {description ?? message ?? meta.description} {onRetry && ( - )} diff --git a/apps/mobile/src/components/security-agent/dashboard-screen.tsx b/apps/mobile/src/components/security-agent/dashboard-screen.tsx index 7a602ca754..7cf0939056 100644 --- a/apps/mobile/src/components/security-agent/dashboard-screen.tsx +++ b/apps/mobile/src/components/security-agent/dashboard-screen.tsx @@ -11,6 +11,7 @@ import { MoreHorizontal, RefreshCw, Settings, ShieldAlert } from 'lucide-react-n import { useState } from 'react'; import { Pressable, RefreshControl, View } from 'react-native'; +import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; import { DashboardSections } from '@/components/security-agent/dashboard-sections'; import { Skeleton } from '@/components/ui/skeleton'; @@ -192,16 +193,12 @@ export function DashboardScreen({ scope }: Readonly<{ scope: string }>) { )} {dashboardStats.isError && !data ? ( - { - void dashboardStats.refetch(); - }} - > - - Could not load dashboard data. Tap to retry. - - + void dashboardStats.refetch()} + /> ) : null} {data ? ( 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..8ba9c93fd7 100644 --- a/apps/mobile/src/components/security-agent/scope-entry-screen.tsx +++ b/apps/mobile/src/components/security-agent/scope-entry-screen.tsx @@ -1,13 +1,13 @@ 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 { QueryError } from '@/components/query-error'; 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 { @@ -40,17 +40,7 @@ function ScopeEntryError({ onRetry }: Readonly<{ onRetry: () => void }>) { return ( - - - - Could not load Security Agent. Tap to retry. - - - + ); } From 6abc483b71b904163f57d110c48e97f25a16e825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:41:15 +0200 Subject: [PATCH 036/166] feat(mobile): safe external-link helper --- .../components/code-reviewer/review-detail-screen.tsx | 5 +++-- apps/mobile/src/lib/external-link.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/src/lib/external-link.ts 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 4e111a6832..95d32f96c0 100644 --- a/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx @@ -1,5 +1,5 @@ import * as Haptics from 'expo-haptics'; -import { Alert, Linking, View } from 'react-native'; +import { Alert, View } from 'react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { @@ -13,6 +13,7 @@ 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'; @@ -143,7 +144,7 @@ export function ReviewDetailScreen({ - - - - - - ); -} diff --git a/apps/mobile/src/components/kiloclaw/rename-instance-modal.tsx b/apps/mobile/src/components/rename-modal.tsx similarity index 50% rename from apps/mobile/src/components/kiloclaw/rename-instance-modal.tsx rename to apps/mobile/src/components/rename-modal.tsx index 7897ecfe2f..7023b56bd4 100644 --- a/apps/mobile/src/components/kiloclaw/rename-instance-modal.tsx +++ b/apps/mobile/src/components/rename-modal.tsx @@ -1,24 +1,33 @@ -import { useEffect, useRef } from 'react'; +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'; -type RenameInstanceModalProps = { - defaultName: string; - onSubmit: (name: string) => void; +type RenameModalProps = { + visible: boolean; + title: string; + placeholder: string; + initialValue: string; + onSave: (name: string) => Promise; onClose: () => void; }; -export function RenameInstanceModal({ - defaultName, - onSubmit, +export function RenameModal({ + visible, + title, + placeholder, + initialValue, + onSave, onClose, -}: Readonly) { +}: Readonly) { const colors = useThemeColors(); - const nameRef = useRef(defaultName); + 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(() => { @@ -33,41 +42,65 @@ export function RenameInstanceModal({ }; }, []); + 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(); }} > - Rename Instance + {title} { nameRef.current = val; + const trimmed = val.trim(); + setCanSave(trimmed.length > 0 && trimmed !== initialValue); }} autoFocus={Platform.OS !== 'android'} maxLength={50} + editable={!pending} /> + {errorText ? {errorText} : null} - diff --git a/apps/mobile/src/lib/hooks/use-kiloclaw-mutations.ts b/apps/mobile/src/lib/hooks/use-kiloclaw-mutations.ts index 61f4226d91..a36c31bc02 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, @@ -354,15 +358,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({ diff --git a/apps/mobile/src/lib/hooks/use-organization-mutations.ts b/apps/mobile/src/lib/hooks/use-organization-mutations.ts index 3d3f14871f..f6601e37f2 100644 --- a/apps/mobile/src/lib/hooks/use-organization-mutations.ts +++ b/apps/mobile/src/lib/hooks/use-organization-mutations.ts @@ -83,8 +83,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,7 +96,6 @@ export function useOrganizationMutations(organizationId: string) { if (context?.previousList) { queryClient.setQueryData(listKey, context.previousList); } - onMutationError(error); }, onSettled: invalidateAll, }), From bab159b361b0233e971286c68dc67b69376f465f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:43:08 +0200 Subject: [PATCH 039/166] refactor(mobile): use app-shared money formatting everywhere --- .../components/organization/credit-activity-screen.tsx | 5 +++-- .../src/components/organization/invoices-screen.tsx | 3 ++- apps/mobile/src/components/organization/member-row.tsx | 3 ++- .../src/components/organization/org-usage-stats.tsx | 4 ++-- apps/mobile/src/components/profile-credits-card.tsx | 9 ++++++--- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/components/organization/credit-activity-screen.tsx b/apps/mobile/src/components/organization/credit-activity-screen.tsx index 84f9a5a4cd..7c55663205 100644 --- a/apps/mobile/src/components/organization/credit-activity-screen.tsx +++ b/apps/mobile/src/components/organization/credit-activity-screen.tsx @@ -1,4 +1,4 @@ -import { fromMicrodollars } from '@kilocode/app-shared/utils'; +import { formatDollars, fromMicrodollars } from '@kilocode/app-shared/utils'; import { Receipt } from 'lucide-react-native'; import { FlatList, View } from 'react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; @@ -45,7 +45,8 @@ function CreditRow({ transaction }: Readonly<{ transaction: CreditTransaction }> {title} - {isPositive ? '+' : '-'}${Math.abs(amount).toFixed(2)} + {isPositive ? '+' : '-'} + {formatDollars(Math.abs(amount))} diff --git a/apps/mobile/src/components/organization/invoices-screen.tsx b/apps/mobile/src/components/organization/invoices-screen.tsx index 165c9bfb34..2490c5a70f 100644 --- a/apps/mobile/src/components/organization/invoices-screen.tsx +++ b/apps/mobile/src/components/organization/invoices-screen.tsx @@ -1,3 +1,4 @@ +import { formatCents } from '@kilocode/app-shared/utils'; import { FileText } from 'lucide-react-native'; import { FlatList, View } from 'react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; @@ -46,7 +47,7 @@ function InvoiceRow({ invoice }: Readonly<{ invoice: OrgInvoice }>) { {title} - ${(invoice.amount_due / 100).toFixed(2)} + {formatCents(invoice.amount_due)} 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/org-usage-stats.tsx b/apps/mobile/src/components/organization/org-usage-stats.tsx index 83cae241d2..c5c894977f 100644 --- a/apps/mobile/src/components/organization/org-usage-stats.tsx +++ b/apps/mobile/src/components/organization/org-usage-stats.tsx @@ -1,4 +1,4 @@ -import { fromMicrodollars } from '@kilocode/app-shared/utils'; +import { formatDollars, fromMicrodollars } from '@kilocode/app-shared/utils'; import { View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; @@ -51,7 +51,7 @@ export function OrgUsageStats({ organizationId }: Readonly) ) : ( - + diff --git a/apps/mobile/src/components/profile-credits-card.tsx b/apps/mobile/src/components/profile-credits-card.tsx index 0657f8d576..e840f1c4e5 100644 --- a/apps/mobile/src/components/profile-credits-card.tsx +++ b/apps/mobile/src/components/profile-credits-card.tsx @@ -1,4 +1,5 @@ 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'; @@ -54,7 +55,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) @@ -126,7 +129,7 @@ export function CreditsCard({ enabled, orgs }: Readonly) { {!balanceLoading && !balanceError && ( - ${balanceDollars.toFixed(2)} + {formatDollars(balanceDollars)} {creditsLoading ? ( @@ -136,7 +139,7 @@ export function CreditsCard({ enabled, orgs }: Readonly) { earliestExpiry != null && ( - ${expiringTotal.toFixed(2)} in bonus credits expiring{' '} + {formatDollars(expiringTotal)} in bonus credits expiring{' '} {parseTimestamp(earliestExpiry).toLocaleDateString(undefined, { month: 'short', day: 'numeric', From 71d08d2a456d88d6d959de13c30ccf66372d7343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 10:52:34 +0200 Subject: [PATCH 040/166] fix(mobile): remount RenameModal per open for a clean slate --- .../kiloclaw/[instance-id]/dashboard.tsx | 25 ++++++++++--------- .../components/organization/hub-screen.tsx | 3 +-- apps/mobile/src/components/rename-modal.tsx | 6 ++--- 3 files changed, 17 insertions(+), 17 deletions(-) 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 983a6ee687..17d461b497 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx @@ -264,18 +264,19 @@ export default function DashboardScreen() { - { - await mutations.renameInstance.mutateAsync({ name }); - }} - onClose={() => { - setRenameVisible(false); - }} - /> + {renameVisible && ( + { + await mutations.renameInstance.mutateAsync({ name }); + }} + onClose={() => { + setRenameVisible(false); + }} + /> + )} ); } diff --git a/apps/mobile/src/components/organization/hub-screen.tsx b/apps/mobile/src/components/organization/hub-screen.tsx index fe602d2f50..868545babf 100644 --- a/apps/mobile/src/components/organization/hub-screen.tsx +++ b/apps/mobile/src/components/organization/hub-screen.tsx @@ -126,9 +126,8 @@ export function OrganizationHubScreen() { - {org && ( + {renameVisible && org && ( void; }; +// 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({ - visible, title, placeholder, initialValue, @@ -64,7 +64,7 @@ export function RenameModal({ }; return ( - + Date: Sat, 11 Jul 2026 10:53:27 +0200 Subject: [PATCH 041/166] fix(mobile): empty-state placement, retry spinners, review money format --- .../src/app/(app)/agent-chat/[session-id].tsx | 1 + .../code-reviewer/review-detail-screen.tsx | 7 +++++-- .../components/code-reviewer/review-list-screen.tsx | 5 ++++- apps/mobile/src/components/profile-screen.tsx | 2 ++ apps/mobile/src/components/query-error.tsx | 2 +- .../components/security-agent/dashboard-screen.tsx | 1 + .../security-agent/scope-entry-screen.tsx | 13 +++++++++++-- 7 files changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx index 29398fe603..3ca9d314d4 100644 --- a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx +++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx @@ -47,6 +47,7 @@ export default function SessionDetailScreen() { title="Could not load session" description="Failed to load session details" onRetry={() => void sessionQuery.refetch()} + isRetrying={sessionQuery.isFetching} /> ); 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 95d32f96c0..667fc25683 100644 --- a/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx @@ -6,6 +6,7 @@ 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'; @@ -50,7 +51,7 @@ export function ReviewDetailScreen({ scope, reviewId, }: Readonly<{ scope: string; reviewId: string }>) { - const { data, isLoading, isError, refetch } = useReviewDetail(reviewId); + const { data, isLoading, isError, isFetching, refetch } = useReviewDetail(reviewId); const cancelReview = useCancelReview(scope); const retriggerReview = useRetriggerReview(scope); @@ -63,6 +64,7 @@ export function ReviewDetailScreen({ variant="server" title="Could not load review" onRetry={() => void refetch()} + isRetrying={isFetching} /> @@ -92,6 +94,7 @@ export function ReviewDetailScreen({ variant="server" title="Could not load review" onRetry={() => void refetch()} + isRetrying={isFetching} /> @@ -127,7 +130,7 @@ export function ReviewDetailScreen({ ) : null} {review.total_cost_musd != null && review.total_cost_musd > 0 ? ( - + ) : null} {tokenUsage.input > 0 || tokenUsage.output > 0 ? ( 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 f73d829083..2897dae3a8 100644 --- a/apps/mobile/src/components/code-reviewer/review-list-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/review-list-screen.tsx @@ -45,7 +45,7 @@ function reviewTime(review: Review): Date { export function ReviewListScreen({ scope }: Readonly<{ scope: string }>) { const router = useRouter(); - const { data, isLoading, isError, refetch } = useReviewList(scope); + const { data, isLoading, isError, isFetching, refetch } = useReviewList(scope); return ( @@ -69,6 +69,7 @@ export function ReviewListScreen({ scope }: Readonly<{ scope: string }>) { placement="top" title="Could not load reviews" onRetry={() => void refetch()} + isRetrying={isFetching} /> )} @@ -78,12 +79,14 @@ export function ReviewListScreen({ scope }: Readonly<{ scope: string }>) { placement="top" title="Could not load reviews" onRetry={() => void refetch()} + isRetrying={isFetching} /> )} {!isLoading && data?.success && data.reviews.length === 0 && ( void refetchProviders()} + isRetrying={providersFetching} /> )} diff --git a/apps/mobile/src/components/query-error.tsx b/apps/mobile/src/components/query-error.tsx index 9baba30682..7dea7f4302 100644 --- a/apps/mobile/src/components/query-error.tsx +++ b/apps/mobile/src/components/query-error.tsx @@ -49,7 +49,7 @@ const VARIANT_META: Record< type QueryErrorProps = { variant?: QueryErrorVariant; title?: string; - /** Legacy description prop, kept for existing call sites — prefer `description`. */ + /** Same as `description`, kept for existing call sites. New call sites should use `description` instead. */ message?: string; description?: string; onRetry?: () => void; diff --git a/apps/mobile/src/components/security-agent/dashboard-screen.tsx b/apps/mobile/src/components/security-agent/dashboard-screen.tsx index 7cf0939056..25e0af52d2 100644 --- a/apps/mobile/src/components/security-agent/dashboard-screen.tsx +++ b/apps/mobile/src/components/security-agent/dashboard-screen.tsx @@ -198,6 +198,7 @@ export function DashboardScreen({ scope }: Readonly<{ scope: string }>) { placement="top" title="Could not load dashboard data" onRetry={() => void dashboardStats.refetch()} + isRetrying={dashboardStats.isFetching} /> ) : null} 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 8ba9c93fd7..af5338ac6c 100644 --- a/apps/mobile/src/components/security-agent/scope-entry-screen.tsx +++ b/apps/mobile/src/components/security-agent/scope-entry-screen.tsx @@ -36,11 +36,19 @@ function ScopeEntrySkeleton() { ); } -function ScopeEntryError({ onRetry }: Readonly<{ onRetry: () => void }>) { +function ScopeEntryError({ + onRetry, + isRetrying, +}: Readonly<{ onRetry: () => void; isRetrying: boolean }>) { return ( - + ); } @@ -80,6 +88,7 @@ export function ScopeEntryScreen({ scope }: Readonly<{ scope: string }>) { void permission.refetch(); void config.refetch(); }} + isRetrying={permission.isFetching || config.isFetching} /> ); } From 2ff86ca1f59bd4e34fc8e567063e77c6ee7e75da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:00:46 +0200 Subject: [PATCH 042/166] fix(mobile): home surfaces query failures instead of hiding sections --- .../home/agent-sessions-section.tsx | 8 +++- .../src/components/home/home-screen.tsx | 47 +++++++++++++++++-- .../src/lib/hooks/use-agent-sessions.ts | 9 ++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/components/home/agent-sessions-section.tsx b/apps/mobile/src/components/home/agent-sessions-section.tsx index e940862cc3..facdff5d18 100644 --- a/apps/mobile/src/components/home/agent-sessions-section.tsx +++ b/apps/mobile/src/components/home/agent-sessions-section.tsx @@ -7,6 +7,7 @@ import { } from '@/components/agents/session-list-helpers'; 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') { diff --git a/apps/mobile/src/components/home/home-screen.tsx b/apps/mobile/src/components/home/home-screen.tsx index cba38176cb..c5f48a0269 100644 --- a/apps/mobile/src/components/home/home-screen.tsx +++ b/apps/mobile/src/components/home/home-screen.tsx @@ -16,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'; @@ -81,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, }); @@ -125,12 +130,16 @@ export function HomeScreen() { {renderKiloClawSlot({ instances: instances ?? [], instancesError, + handleRetryInstances: () => void refetchInstances(), unreadByBadgeBucket, })} {renderSessionsOrPromo({ hasAnySession, organizationId, + sessionsError: storedIsError, + sessionsLoadedEmpty: storedIsSuccess && !hasAnySession, + handleRetrySessions: () => void refetchSessions(), })} {hasAnySession ? ( @@ -148,8 +157,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 ( @@ -169,14 +182,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/lib/hooks/use-agent-sessions.ts b/apps/mobile/src/lib/hooks/use-agent-sessions.ts index 3413c9c07b..d1da4a5b67 100644 --- a/apps/mobile/src/lib/hooks/use-agent-sessions.ts +++ b/apps/mobile/src/lib/hooks/use-agent-sessions.ts @@ -243,6 +243,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, From b801cbb9840f7d4d03e9279f1184a2b594feab53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:02:20 +0200 Subject: [PATCH 043/166] fix(mobile): security-agent capability states and audit access --- .../security-agent/scope-entry-screen.tsx | 50 +++- .../security-agent/scope-list-screen.tsx | 23 +- .../lib/hooks/use-security-agent-mutations.ts | 202 ++++++++++++++ .../src/lib/hooks/use-security-agent.ts | 261 ++++-------------- 4 files changed, 322 insertions(+), 214 deletions(-) create mode 100644 apps/mobile/src/lib/hooks/use-security-agent-mutations.ts 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 af5338ac6c..f6817874ce 100644 --- a/apps/mobile/src/components/security-agent/scope-entry-screen.tsx +++ b/apps/mobile/src/components/security-agent/scope-entry-screen.tsx @@ -1,7 +1,11 @@ -import { isPersonalSecurityScope } from '@kilocode/app-shared/security-agent'; +import { + getSecurityAgentAuditUrl, + isPersonalSecurityScope, +} from '@kilocode/app-shared/security-agent'; import { useRouter } from 'expo-router'; +import { MoreHorizontal } from 'lucide-react-native'; import { useEffect } from 'react'; -import { View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; @@ -10,11 +14,14 @@ import { SecurityAgentSetup } from '@/components/security-agent/security-agent-s import { Skeleton } from '@/components/ui/skeleton'; import { getGitHubIntegrationUrl } from '@/lib/agent-github-integration'; import { WEB_BASE_URL } from '@/lib/config'; +import { openExternalUrl } from '@/lib/external-link'; import { + useSecurityAgentCapability, useSecurityAgentConfig, useSecurityAgentPermissionStatus, useSecurityAgentRepositories, } from '@/lib/hooks/use-security-agent'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { getSecurityAgentPath } from '@/lib/security-agent'; function ScopeEntrySkeleton() { @@ -55,12 +62,14 @@ function ScopeEntryError({ export function ScopeEntryScreen({ scope }: Readonly<{ scope: string }>) { const router = useRouter(); + const colors = useThemeColors(); 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; @@ -74,7 +83,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) { @@ -87,16 +101,36 @@ export function ScopeEntryScreen({ scope }: Readonly<{ scope: string }>) { onRetry={() => { void permission.refetch(); void config.refetch(); + void capability.refetch(); }} - isRetrying={permission.isFetching || config.isFetching} + 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 ? ( + { + 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" + > + + + ) : null; + if (!hasIntegration) { return ( - + ) { if (!hasPermissions) { return ( - + { router.push(getSecurityAgentPath(scope)); }; + if (isError && !orgs) { + return ( + + + void refetch()} + isRetrying={isFetching} + /> + + ); + } + return ( 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..2016697f32 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts @@ -0,0 +1,202 @@ +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'; + +// 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 }); +} + +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); + }, + }); +} diff --git a/apps/mobile/src/lib/hooks/use-security-agent.ts b/apps/mobile/src/lib/hooks/use-security-agent.ts index af3e3ba323..465ad99cb2 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 OrganizationRole, 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,13 +102,31 @@ 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 disabled query never conflates with a failed one here — react-query +// reports `isLoading`/`isError` as `false` for `enabled: false` — but 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. Consumers that must tell those apart use +// `useSecurityAgentOrgRoleQuery`/`useSecurityAgentCapability` below; existing +// boolean-only callers are unchanged. +function useSecurityAgentOrgRoleQuery(scope: string) { const trpc = useTRPC(); - const { data: orgs } = useQuery({ + const query = useQuery({ ...trpc.organizations.list.queryOptions(), enabled: !isPersonalSecurityScope(scope), }); - return orgs?.find(org => org.organizationId === scope)?.role; + return { + role: query.data?.find(org => org.organizationId === scope)?.role, + isLoading: query.isLoading, + isError: query.isError, + isFetching: query.isFetching, + refetch: query.refetch, + }; +} + +export function useSecurityAgentOrgRole(scope: string): OrganizationRole | undefined { + return useSecurityAgentOrgRoleQuery(scope).role; } export function useSecurityAgentEditCapability(scope: string): boolean { @@ -118,6 +134,26 @@ export function useSecurityAgentEditCapability(scope: string): boolean { 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. export function useSecurityAnalysisCapacity(scope: string): { @@ -140,188 +176,3 @@ export function useSecurityAnalysisCapacity(scope: string): { 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); - }, - }); -} From 8630982e6aa1a3da045278996f01450139b7c747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:05:04 +0200 Subject: [PATCH 044/166] fix(mobile): security-agent repository states --- .../security-agent/dashboard-screen.tsx | 12 ++- .../security-agent/finding-list-screen.tsx | 15 ++- .../repository-settings-screen.tsx | 98 ++++++++++++++----- 3 files changed, 96 insertions(+), 29 deletions(-) diff --git a/apps/mobile/src/components/security-agent/dashboard-screen.tsx b/apps/mobile/src/components/security-agent/dashboard-screen.tsx index 25e0af52d2..b43c7618a1 100644 --- a/apps/mobile/src/components/security-agent/dashboard-screen.tsx +++ b/apps/mobile/src/components/security-agent/dashboard-screen.tsx @@ -71,7 +71,15 @@ export function DashboardScreen({ scope }: Readonly<{ scope: string }>) { })(); }; + // 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 ); @@ -141,9 +149,11 @@ export function DashboardScreen({ scope }: Readonly<{ scope: string }>) { {repoFullName ?? 'All repositories'} 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 2bd57372e2..146b57c192 100644 --- a/apps/mobile/src/components/security-agent/finding-list-screen.tsx +++ b/apps/mobile/src/components/security-agent/finding-list-screen.tsx @@ -26,6 +26,7 @@ import { } from '@/lib/hooks/use-security-agent'; import { useSecurityFindings } from '@/lib/hooks/use-security-findings'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { cn } from '@/lib/utils'; type FindingListScreenProps = { scope: string; @@ -67,6 +68,9 @@ 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 () => { @@ -87,11 +91,18 @@ export function FindingListScreen({ scope, routeParams }: Readonly { - setShowFilterModal(true); + if (!filterUnavailable) { + setShowFilterModal(true); + } }} + 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' + )} > ) )} - {(repositories.data ?? []).map(repo => ( - { - toggleRepo(repo.id); - }} - accessibilityRole="checkbox" - accessibilityState={{ checked: selectedIds.includes(repo.id) }} - > - - {repo.private ? : null} - - {repo.fullName} - - - - - ))} - {selectedIds.length === 0 && ( - Select at least one repository. + {repositories.isError && ( + void repositories.refetch()} + isRetrying={repositories.isFetching} + /> )} + {!repositories.isLoading && !repositories.isError && repositories.data?.length === 0 ? ( + { + void openExternalUrl( + getGitHubIntegrationUrl( + WEB_BASE_URL, + isPersonalSecurityScope(scope) ? undefined : scope + ), + { label: 'GitHub App settings' } + ); + }} + > + Manage GitHub App access + + } + /> + ) : null} + {!repositories.isLoading && + !repositories.isError && + (repositories.data ?? []).map(repo => ( + { + toggleRepo(repo.id); + }} + accessibilityRole="checkbox" + accessibilityState={{ checked: selectedIds.includes(repo.id) }} + > + + {repo.private ? : null} + + {repo.fullName} + + + + + ))} + {!repositories.isLoading && + !repositories.isError && + (repositories.data?.length ?? 0) > 0 && + selectedIds.length === 0 && ( + + Select at least one repository. + + )} )} From f56eeac3cf11193f73926f0227ee9fb632d177a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:07:55 +0200 Subject: [PATCH 045/166] fix(mobile): session list retry, filtered empty state, honest refresh --- .../agents/session-list-content.tsx | 104 ++++++++++++++---- .../agents/session-list-header-actions.tsx | 45 ++++++++ .../components/agents/session-list-screen.tsx | 93 +++++++++------- .../src/lib/hooks/use-agent-sessions.ts | 7 +- 4 files changed, 187 insertions(+), 62 deletions(-) create mode 100644 apps/mobile/src/components/agents/session-list-header-actions.tsx diff --git a/apps/mobile/src/components/agents/session-list-content.tsx b/apps/mobile/src/components/agents/session-list-content.tsx index 42e2880536..f4fe496e40 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, @@ -10,6 +10,7 @@ import { } from 'react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { toast } from 'sonner-native'; import { type SessionItem, type SessionSection } from '@/components/agents/session-list-helpers'; import { RemoteSessionRow, StoredSessionRow } from '@/components/agents/session-row'; @@ -32,12 +33,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 +52,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( @@ -66,20 +87,31 @@ export function AgentSessionListContent({ const listHeader = useMemo( () => ( - - - + + + + + + {isSearchPending ? ( + + + + Searching… + + + ) : null} ), - [colors.mutedForeground, onSearchChange] + [colors.mutedForeground, isSearchPending, onSearchChange] ); const emptyStateAction = useMemo( @@ -92,18 +124,33 @@ 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] ); const organizationIdBySessionId = useMemo( @@ -112,7 +159,16 @@ export function AgentSessionListContent({ ); const handleRefresh = useCallback(() => { - void refetch(); + void (async () => { + setRefreshing(true); + try { + await refetch(); + } catch { + toast.error('Could not refresh sessions — showing the last saved list.'); + } finally { + setRefreshing(false); + } + })(); }, [refetch]); const renderItem = useCallback( @@ -178,7 +234,7 @@ export function AgentSessionListContent({ if (isError) { return ( - void refetch()} /> + ); } @@ -211,7 +267,7 @@ export function AgentSessionListContent({ renderSectionHeader={renderSectionHeader} keyExtractor={keyExtractor} ListHeaderComponent={listHeader} - ListEmptyComponent={listEmptyComponent} + ListEmptyComponent={hasActiveQuery ? filteredEmptyComponent : null} ListFooterComponent={ isFetchingNextPage ? ( @@ -224,7 +280,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..e246d28d68 --- /dev/null +++ b/apps/mobile/src/components/agents/session-list-header-actions.tsx @@ -0,0 +1,45 @@ +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; + onNewSession: () => void; + onOpenFilters: () => void; +}; + +export function SessionListHeaderActions({ + hasActiveFilter, + onNewSession, + onOpenFilters, +}: Readonly) { + const colors = useThemeColors(); + + return ( + + + + + + + + + ); +} diff --git a/apps/mobile/src/components/agents/session-list-screen.tsx b/apps/mobile/src/components/agents/session-list-screen.tsx index 1ef998dbd1..4aa888a77b 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,21 @@ 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]); + const refetchRef = useRef(refetch); useEffect(() => { refetchRef.current = refetch; @@ -140,6 +160,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 +183,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 +203,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 +224,9 @@ export function AgentSessionListScreen() { activeSessionIds, activeSessions, dateGroups, + effectiveSearchQuery, projectFilter, search.dateGroups, - searchQuery, storedSessions, ]); @@ -220,6 +248,11 @@ export function AgentSessionListScreen() { const hasActiveFilter = platformFilter.length > 0 || projectFilter.length > 0; + const handleClearQuery = useCallback(() => { + handleClearSearch(); + setFilters({ platformFilter: [], projectFilter: [] }); + }, [handleClearSearch, setFilters]); + return ( - { - 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); + }} + /> } /> @@ -276,13 +290,18 @@ export function AgentSessionListScreen() { sections={sections} storedSessions={storedSessions} hasAnySessions={storedSessions.length > 0 || activeSessions.length > 0} - isLoading={isLoading || !ready || (isSearching && search.isPending)} - isError={isError || (isSearching && search.isError)} + isLoading={isLoading || !ready} + isSearchPending={isSearchPending} + isError={contentIsError} isFetchingNextPage={isFetchingNextPage} refetch={refetch} + 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/lib/hooks/use-agent-sessions.ts b/apps/mobile/src/lib/hooks/use-agent-sessions.ts index d1da4a5b67..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 ──────────────────────────────────────────────────────── From 13fbe409e742c01f6685caf56bc86f4560597162 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:08:37 +0200 Subject: [PATCH 046/166] fix(mobile): entitlement-aware kiloclaw list and reachable create CTA --- .../app/(app)/(tabs)/(1_kiloclaw)/index.tsx | 22 ++++++-- .../src/components/kiloclaw/instance-card.tsx | 51 ++++++++++++++++--- .../kiloclaw/instance-list-screen.tsx | 44 +++++++++++++++- 3 files changed, 104 insertions(+), 13 deletions(-) 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..227b85c957 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx @@ -4,7 +4,10 @@ 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'; @@ -28,7 +31,13 @@ export default function KiloClawTab() { 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; @@ -47,9 +56,11 @@ export default function KiloClawTab() { })(); }, [refetchInstances]); - 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 ( @@ -63,7 +74,7 @@ export default function KiloClawTab() { if (instancesQuery.isError) { void instancesQuery.refetch(); } - if (onboardingQueryEnabled && onboardingQuery.isError) { + if (onboardingQuery.isError) { void onboardingQuery.refetch(); } }} @@ -89,6 +100,7 @@ export default function KiloClawTab() { onCreate={() => { router.push('/(app)/onboarding' as Href); }} + personalAccessIssue={personalAccessIssue} /> ); } diff --git a/apps/mobile/src/components/kiloclaw/instance-card.tsx b/apps/mobile/src/components/kiloclaw/instance-card.tsx index 2cc02ca4d5..ca6aaa587b 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,6 +108,10 @@ export function KiloClawCard({ onSettingsPress?.(instance.sandboxId); }; + const handleAccessIssuePress = () => { + accessIssue?.onOpen(); + }; + return ( @@ -95,7 +119,8 @@ export function KiloClawCard({ ) : null} + {accessIssue ? ( + + + + {accessIssue.label} + + + + ) : null} ); } diff --git a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx index 4d18d1e838..d048cc590b 100644 --- a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx @@ -5,12 +5,15 @@ 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 { WEB_BASE_URL } from '@/lib/config'; +import { openExternalUrl } from '@/lib/external-link'; import { type ClawInstance } from '@/lib/hooks/use-instance-context'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; @@ -23,8 +26,32 @@ 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', +}; + +const SUBSCRIBE_SUBCASES: ReadonlySet = new Set([ + 'trial_expired', + 'subscription_canceled', + 'subscription_past_due', +]); + +function resolveAccessIssueUrl(subcase: AccessRequiredSubcase): string { + return SUBSCRIBE_SUBCASES.has(subcase) ? `${WEB_BASE_URL}/claw` : WEB_BASE_URL; +} + function splitInstances(instances: ClawInstance[]) { return { personal: instances.filter(instance => instance.organizationId === null), @@ -39,6 +66,7 @@ function InstanceSection({ onSettingsPress, unreadByBadgeBucket, showCount, + accessIssue, }: Readonly<{ title: string; instances: ClawInstance[]; @@ -46,6 +74,7 @@ function InstanceSection({ onSettingsPress: (sandboxId: string) => void; unreadByBadgeBucket?: Map; showCount: boolean; + accessIssue?: KiloClawCardAccessIssue | null; }>) { if (instances.length === 0) { return null; @@ -69,6 +98,7 @@ function InstanceSection({ onPress={onSelect} onSettingsPress={onSettingsPress} unreadCount={unreadByBadgeBucket?.get(badgeBucketForInstance(instance.sandboxId)) ?? 0} + accessIssue={accessIssue} /> ))} @@ -85,9 +115,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(); @@ -109,7 +148,7 @@ export function InstanceListScreen({ showsVerticalScrollIndicator={false} refreshControl={} > - {instances.length === 0 ? ( + {personal.length === 0 ? ( ) : 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 ? ( diff --git a/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx b/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx index 42790c9798..2e01b4a161 100644 --- a/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx +++ b/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx @@ -12,6 +12,7 @@ import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanim import { openModelPicker } from '@/components/agents/model-selector'; import { BitbucketConnectForm } from '@/components/code-reviewer/bitbucket-connect-form'; +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'; @@ -20,6 +21,7 @@ import { TabScreenScrollView } from '@/components/tab-screen'; import { PLATFORM_CAPABILITIES } from '@/lib/code-reviewer-config'; import { useAvailableModels } from '@/lib/hooks/use-available-models'; import { + classifyProviderState, useBitbucketReadiness, type useReviewConfig, useSaveReviewConfig, @@ -34,18 +36,43 @@ export function BitbucketOverview({ config, toggle, canEdit, + permissionLoading, }: Readonly<{ scope: string; config: ReturnType; toggle: ReturnType; canEdit: boolean; + permissionLoading: boolean; }>) { const router = useRouter(); 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'; const pushField = (field: string) => { router.push(`/(app)/(tabs)/(3_profile)/code-reviewer/${scope}/bitbucket/${field}` as Href); 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..b550f46a30 --- /dev/null +++ b/apps/mobile/src/components/code-reviewer/platform-overview-rows.ts @@ -0,0 +1,89 @@ +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'; + +const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1); + +export 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`, + }, + ]; +} 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 986a526be4..9b434c821b 100644 --- a/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx @@ -1,19 +1,13 @@ 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 { 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 } from '@/components/code-reviewer/platform-overview-rows'; import { ProviderConnectCard } from '@/components/code-reviewer/provider-connect-card'; +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'; @@ -23,18 +17,30 @@ 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'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1); +function PlatformErrorScreen({ + title, + onRetry, + isRetrying, +}: Readonly<{ title: string; onRetry: () => void; isRetrying: boolean }>) { + return ( + + + + + ); +} export function PlatformOverviewScreen({ scope, @@ -42,12 +48,14 @@ export function PlatformOverviewScreen({ }: 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 @@ -66,14 +74,55 @@ export function PlatformOverviewScreen({ ); } + if (permission.status === 'error') { + return ( + { + 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'; const pushField = (field: string) => { router.push(`/(app)/(tabs)/(3_profile)/code-reviewer/${scope}/${platform}/${field}` as Href); @@ -83,65 +132,22 @@ export function PlatformOverviewScreen({ 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', - }, - { - 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`, + : 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 }); + }, + }); }, - ]; + }); const resolveRowOnPress = (row: NonNullable[number]) => { if (!canEdit) { 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..44ff763c33 --- /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. +export 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/hooks/use-code-reviewer.ts b/apps/mobile/src/lib/hooks/use-code-reviewer.ts index eb74c1a0ae..d5981eecc9 100644 --- a/apps/mobile/src/lib/hooks/use-code-reviewer.ts +++ b/apps/mobile/src/lib/hooks/use-code-reviewer.ts @@ -279,18 +279,19 @@ 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 { + classifyPermission, + classifyProviderState, + type PermissionState, + type ProviderState, + useCanEditReviewer, + 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-reviewer-permission.ts b/apps/mobile/src/lib/hooks/use-reviewer-permission.ts new file mode 100644 index 0000000000..10bc8ff930 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-reviewer-permission.ts @@ -0,0 +1,62 @@ +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'; + +export { + classifyPermission, + classifyProviderState, + type PermissionState, + type ProviderState, +} from '@/lib/code-reviewer-status'; + +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(), + }); +} + +export function useCanEditReviewer(scope: string) { + const permission = useReviewerPermission(scope); + return permission.status === 'ready' && permission.canEdit; +} + +/** + * 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]); +} From 2796c105138f25718bd4d6e27116361d57a707ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:11:19 +0200 Subject: [PATCH 050/166] feat(mobile): optimistic session rename and delete --- .../src/lib/hooks/use-session-mutations.ts | 109 ++++++++++++++++-- 1 file changed, 102 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/lib/hooks/use-session-mutations.ts b/apps/mobile/src/lib/hooks/use-session-mutations.ts index dd3e7d303c..69200189dc 100644 --- a/apps/mobile/src/lib/hooks/use-session-mutations.ts +++ b/apps/mobile/src/lib/hooks/use-session-mutations.ts @@ -1,41 +1,136 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { type inferRouterOutputs, type RootRouter } from '@kilocode/trpc'; +import { type QueryKey, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useRef } from 'react'; import { toast } from 'sonner-native'; import { invalidateAgentSessionQueries } from '@/lib/agent-session-cache'; import { useTRPC } from '@/lib/trpc'; +type RouterOutputs = inferRouterOutputs; +type SessionsListPage = RouterOutputs['cliSessionsV2']['list']; +type SessionsListData = { pages: SessionsListPage[]; pageParams: unknown[] }; +type SessionsListSnapshot = [QueryKey, SessionsListData | undefined][]; + const onError = (error: { message: string }) => { toast.error(error.message || 'Something went wrong'); }; +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 + ), + })), + }; +} + +function removeStoredSession(data: SessionsListData, sessionId: string): SessionsListData { + return { + ...data, + pages: data.pages.map(page => ({ + ...page, + cliSessions: page.cliSessions.filter(session => session.session_id !== sessionId), + })), + }; +} + export function useSessionMutations() { const trpc = useTRPC(); const queryClient = useQueryClient(); + // ponytail: a ref (not state) is enough — this only gates duplicate taps + // on the same row, it never needs to drive a re-render. + const pendingSessionIds = useRef(new Set()); + 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, + onMutate: async ({ session_id }) => { + const context = await snapshotAndUpdate(data => removeStoredSession(data, session_id)); + return context; + }, + onError: (error, _input, context) => { + rollback(context?.previous); + onError(error); + }, + onSettled: invalidateSessions, }) ); const renameSessionMutation = useMutation( trpc.cliSessionsV2.rename.mutationOptions({ - onSuccess: invalidateSessions, - onError, + onMutate: async ({ session_id, title }) => { + const context = await snapshotAndUpdate(data => + mapStoredSessions(data, session_id, session => ({ ...session, title })) + ); + return context; + }, + onError: (error, _input, context) => { + rollback(context?.previous); + onError(error); + }, + onSettled: invalidateSessions, }) ); + // Optimistic updates already make the row change land instantly, so a + // second tap before the first mutation settles would just be a duplicate + // in-flight request for the same row — drop it instead of firing another. + const withPendingGuard = (sessionId: string, run: () => Promise) => { + if (pendingSessionIds.current.has(sessionId)) { + return; + } + pendingSessionIds.current.add(sessionId); + void (async () => { + try { + await run(); + } catch { + // Already surfaced via the mutation's own onError (toast + rollback). + } finally { + pendingSessionIds.current.delete(sessionId); + } + })(); + }; + return { deleteSession: (sessionId: string) => { - deleteSessionMutation.mutate({ session_id: sessionId }); + withPendingGuard(sessionId, async () => { + const result = await deleteSessionMutation.mutateAsync({ session_id: sessionId }); + return result; + }); }, renameSession: (sessionId: string, title: string) => { - renameSessionMutation.mutate({ session_id: sessionId, title }); + withPendingGuard(sessionId, async () => { + const result = await renameSessionMutation.mutateAsync({ session_id: sessionId, title }); + return result; + }); }, }; } From e7bb7b32d5f4a9750653a2de60cab35156b6f0b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:11:58 +0200 Subject: [PATCH 051/166] fix(mobile): code-reviewer config states --- .../[scope]/[platform]/focus-areas.tsx | 10 ++++++++- .../code-reviewer/[scope]/[platform]/gate.tsx | 1 + .../[scope]/[platform]/repos.tsx | 21 +++++++++++++++++-- .../[scope]/[platform]/style.tsx | 1 + .../code-reviewer/bitbucket-overview.tsx | 18 ++++++++++++++++ .../components/code-reviewer/option-list.tsx | 4 ++++ .../platform-overview-screen.tsx | 16 ++++++++++++++ 7 files changed, 68 insertions(+), 3 deletions(-) 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]/focus-areas.tsx index dba2c3178c..3af139a48e 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]/focus-areas.tsx @@ -16,6 +16,7 @@ import { } from '@/lib/hooks/use-code-reviewer'; import { useValidatedReviewerRouteParams } from '@/lib/hooks/use-reviewer-route-params'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { cn } from '@/lib/utils'; export default function FocusAreasRoute() { const params = useValidatedReviewerRouteParams(); @@ -40,6 +41,7 @@ function FocusAreasRouteContent({ 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(); @@ -63,7 +65,13 @@ function FocusAreasRouteContent({ {REVIEW_FOCUS_AREAS.map(area => ( { toggleArea(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]/gate.tsx index c07d9bedf8..e39248d8eb 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]/gate.tsx @@ -44,6 +44,7 @@ function GateThresholdRouteContent({ options={GATE_THRESHOLDS} selected={data?.gateThreshold} descriptions={DESCRIPTIONS} + disabled={data == null} onSelect={value => { save.mutate({ gateThreshold: value }); }} 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 index 3a440c288a..30e567d09e 100644 --- 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 @@ -20,6 +20,7 @@ import { } from '@/lib/hooks/use-code-reviewer'; import { useValidatedReviewerRouteParams } from '@/lib/hooks/use-reviewer-route-params'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { cn } from '@/lib/utils'; export default function ReposRoute() { const params = useValidatedReviewerRouteParams(); @@ -63,6 +64,7 @@ function ReposRouteContent({ }; const { isLoading: reposLoading, rows: repoRows } = reposByPlatform[platform]; const selectedIds = data?.selectedRepositoryIds ?? []; + const configDisabled = data == null; const setMode = (nextMode: 'all' | 'selected') => { void Haptics.selectionAsync(); @@ -89,7 +91,13 @@ function ReposRouteContent({ (['all', 'selected'] as const).map(option => ( { setMode(option); }} @@ -122,7 +130,16 @@ function ReposRouteContent({ {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]/style.tsx index 1f8251b617..146d7c4087 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]/style.tsx @@ -44,6 +44,7 @@ function ReviewStyleRouteContent({ options={REVIEW_STYLES} selected={data?.reviewStyle} descriptions={DESCRIPTIONS} + disabled={data == null} onSelect={value => { save.mutate({ reviewStyle: value }); }} diff --git a/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx b/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx index 2e01b4a161..b2b7b52869 100644 --- a/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx +++ b/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx @@ -74,6 +74,24 @@ export function BitbucketOverview({ 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); }; diff --git a/apps/mobile/src/components/code-reviewer/option-list.tsx b/apps/mobile/src/components/code-reviewer/option-list.tsx index c0c40320c2..4a77516bfd 100644 --- a/apps/mobile/src/components/code-reviewer/option-list.tsx +++ b/apps/mobile/src/components/code-reviewer/option-list.tsx @@ -12,6 +12,8 @@ type OptionListProps = { onSelect: (value: T) => void; /** 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. */ @@ -21,6 +23,7 @@ export function OptionList({ selected, onSelect, descriptions, + disabled, }: Readonly>) { const router = useRouter(); @@ -34,6 +37,7 @@ export function OptionList({ label={option} description={descriptions?.[option]} selected={selected === option} + disabled={disabled} onPress={() => { onSelect(option); router.back(); 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 9b434c821b..85fc6fb399 100644 --- a/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx @@ -124,6 +124,22 @@ export function PlatformOverviewScreen({ 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); }; From 179282fbc00e3fc44561e2c90ed45ed4eeb49b0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:12:11 +0200 Subject: [PATCH 052/166] fix(mobile): security dashboard sync and status truth --- .../security-agent/dashboard-screen.tsx | 45 ++++++++++++++++--- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/components/security-agent/dashboard-screen.tsx b/apps/mobile/src/components/security-agent/dashboard-screen.tsx index b43c7618a1..aeb501cdae 100644 --- a/apps/mobile/src/components/security-agent/dashboard-screen.tsx +++ b/apps/mobile/src/components/security-agent/dashboard-screen.tsx @@ -10,11 +10,13 @@ import * as WebBrowser from 'expo-web-browser'; import { MoreHorizontal, RefreshCw, Settings, ShieldAlert } from 'lucide-react-native'; import { useState } from 'react'; import { Pressable, RefreshControl, View } from 'react-native'; +import { toast } from 'sonner-native'; import { QueryError } from '@/components/query-error'; 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 { TabScreenScrollView } from '@/components/tab-screen'; import { WEB_BASE_URL } from '@/lib/config'; @@ -42,6 +44,7 @@ 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); @@ -55,16 +58,25 @@ 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); } @@ -164,17 +176,36 @@ 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 ? ( From 55d61c2de36850cfe0b645c109fe628bb3d0f29a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:13:08 +0200 Subject: [PATCH 053/166] fix(mobile): code-reviewer scope list surfaces org errors --- .../code-reviewer/scope-list-screen.tsx | 20 ++- .../components/kiloclaw/onboarding-flow.tsx | 24 ++- .../kiloclaw/onboarding/flow-body.tsx | 6 + .../kiloclaw/onboarding/provisioning-step.tsx | 153 +++++++++++------- .../src/lib/hooks/use-kiloclaw-queries.ts | 28 +++- apps/mobile/src/lib/onboarding/index.ts | 2 + .../machine-provisioning-terminal.test.ts | 43 +++++ apps/mobile/src/lib/onboarding/machine.ts | 20 +++ .../src/lib/onboarding/selectors.test.ts | 42 +++++ apps/mobile/src/lib/onboarding/selectors.ts | 43 ++++- 10 files changed, 317 insertions(+), 64 deletions(-) create mode 100644 apps/mobile/src/lib/onboarding/machine-provisioning-terminal.test.ts 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 a2ee1b6ea4..098b78b6ec 100644 --- a/apps/mobile/src/components/code-reviewer/scope-list-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/scope-list-screen.tsx @@ -3,6 +3,7 @@ import { type Href, useRouter } from 'expo-router'; import { Building2, User } from 'lucide-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'; @@ -13,7 +14,13 @@ 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); @@ -23,6 +30,17 @@ export function ScopeListScreen() { + {isError && ( + void refetch()} + isRetrying={isFetching} + /> + )} { + if (state.provisionStarted && (statusQuery.isError || gatewayReadyQuery.isError)) { + dispatch({ type: 'provisioning-query-errored' }); + } + }, [state.provisionStarted, statusQuery.isError, gatewayReadyQuery.isError]); + useEffect(() => { if (!gatewayReadyData) { return; @@ -278,6 +287,10 @@ export function OnboardingFlow() { dispatch({ type: 'gateway-grace-elapsed' }); }, []); + const onProvisioningTimeout = useCallback(() => { + dispatch({ type: 'provisioning-timeout-elapsed' }); + }, []); + const onDismiss = useCallback(() => { // When provision has started (or an instance exists), land on Home so the // user sees the live status card. Otherwise return to the entry screen. @@ -377,9 +390,12 @@ export function OnboardingFlow() { // pending_settlement without an instance — the subscription is still // activating server-side. `resolveAccessRequiredSubcase` returns null // because this is neither an access-required nor a remediation case. - - Finishing setup — hang tight while we finalize your account. - + + + + Finishing setup — hang tight while we finalize your account. + + )} @@ -436,6 +452,8 @@ export function OnboardingFlow() { onProvisioningComplete={onProvisioningComplete} onRetry={onProvisioningRetry} onGraceElapsed={onGraceElapsed} + onProvisioningTimeout={onProvisioningTimeout} + onContinueInBackground={onDismiss} onOpenInstance={onOpenInstance} /> diff --git a/apps/mobile/src/components/kiloclaw/onboarding/flow-body.tsx b/apps/mobile/src/components/kiloclaw/onboarding/flow-body.tsx index a1c0b0109d..beba22240e 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding/flow-body.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding/flow-body.tsx @@ -20,6 +20,8 @@ type FlowBodyProps = { onProvisioningComplete: () => void; onRetry: () => void; onGraceElapsed: () => void; + onProvisioningTimeout: () => void; + onContinueInBackground: () => void; onOpenInstance: () => void; }; @@ -31,6 +33,8 @@ export function FlowBody(props: Readonly) { onProvisioningComplete, onRetry, onGraceElapsed, + onProvisioningTimeout, + onContinueInBackground, onOpenInstance, } = props; const colors = useThemeColors(); @@ -134,7 +138,9 @@ export function FlowBody(props: Readonly) { state={state} onComplete={onProvisioningComplete} onGraceElapsed={onGraceElapsed} + onProvisioningTimeout={onProvisioningTimeout} onRetry={onRetry} + onContinueInBackground={onContinueInBackground} /> ); diff --git a/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx b/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx index f6e4be0559..c8194f43f8 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx @@ -1,7 +1,8 @@ import { checkGraceExpired, - isProvisioningTerminal, + getProvisioningTerminalReason, type OnboardingState, + type ProvisioningTerminalReason, shouldAdvanceFromProvisioning, } from '@/lib/onboarding'; import { AlertTriangle } from 'lucide-react-native'; @@ -11,7 +12,6 @@ import Animated, { Easing, FadeIn, FadeOut, - LinearTransition, useAnimatedStyle, useSharedValue, withRepeat, @@ -29,34 +29,62 @@ import { DEFAULT_BOT_IDENTITY } from './state'; type ProvisioningStepProps = { state: OnboardingState; onGraceElapsed: () => void; + onProvisioningTimeout: () => void; onComplete: () => void; onRetry: () => void; + onContinueInBackground: () => void; }; -const STATUS_MESSAGE_TEMPLATES = [ - 'Renting a small corner of the cloud…', - 'Handing {name} a name tag…', - 'Unpacking a fresh set of prompts…', - 'Teaching it where the kettle is…', - 'Warming up the silicon…', - 'Running a quick vibe check…', - 'Pouring {name} a cup of coffee…', - 'Double-checking the welcome memo…', -]; - -const STATUS_ROTATE_MS = 2600; +// Overall wall-clock budget for provisioning before we tell the user it's +// stalled, rather than pulsing an indefinite "waking up" message. Resets +// per mount, which happens on every fresh entry into the provisioning step +// (including after a retry — see `flow-body.tsx`'s `key="provisioning"`). +const OVERALL_TIMEOUT_MS = 150_000; const PULSE_PEAK = 1.06; const PULSE_DURATION_MS = 1400; -function personalizeMessages(name: string): readonly string[] { - return STATUS_MESSAGE_TEMPLATES.map(m => m.replace('{name}', name)); +const TERMINAL_CONTENT: Record< + ProvisioningTerminalReason, + { title: string; body: (name: string) => string } +> = { + query_error: { + title: "Couldn't check on setup", + body: name => + `We lost the connection while checking on ${name}'s setup. It may still be running — try again, or check back shortly.`, + }, + instance_stopped: { + title: 'Setup stopped', + body: name => `${name}'s instance stopped unexpectedly during setup.`, + }, + gateway_502: { + title: 'Something stalled', + body: name => + `We couldn't finish setting up ${name}. Try again, or email hi@kilo.ai if this keeps happening.`, + }, + timeout: { + title: 'Taking longer than expected', + body: name => + `Setup for ${name} is taking longer than usual (over ${OVERALL_TIMEOUT_MS / 60_000} minutes).`, + }, +}; + +function provisioningStageMessage(state: OnboardingState): string { + if (state.instanceStatus === 'running' && state.gatewayReady && !state.gatewaySettled) { + return 'Finishing setup'; + } + if (state.instanceStatus === 'running' && !state.gatewayReady) { + return 'Connecting to the agent'; + } + return 'Starting the sandbox'; } export function ProvisioningStep({ state, onGraceElapsed, + onProvisioningTimeout, onComplete, onRetry, + onContinueInBackground, }: Readonly) { const colors = useThemeColors(); @@ -64,16 +92,8 @@ export function ProvisioningStep({ const botName = state.botIdentity?.botName ?? DEFAULT_BOT_IDENTITY.botName; const tint = agentColor(botEmoji); - const messages = useMemo(() => personalizeMessages(botName), [botName]); - const [messageIndex, setMessageIndex] = useState(0); - useEffect(() => { - const id = setInterval(() => { - setMessageIndex(i => (i + 1) % messages.length); - }, STATUS_ROTATE_MS); - return () => { - clearInterval(id); - }; - }, [messages.length]); + const terminalReason = getProvisioningTerminalReason(state); + const stageMessage = useMemo(() => provisioningStageMessage(state), [state]); // Gentle breathing pulse on the avatar tile — signals "actively working" // without the spinner-in-the-middle-of-nowhere look. @@ -92,23 +112,44 @@ export function ProvisioningStep({ transform: [{ scale: pulse.value }], })); - // 502-grace poll: tick once per second while we're holding a 502 and the - // grace window hasn't elapsed. Checked against the pure helper so the - // timing logic stays testable. - const { first502AtMs, gateway502Expired } = state; + // The overall-timeout clock starts at mount, which happens fresh every + // time this step is (re)entered — see the `key="provisioning"` on the + // FlowBody branch that renders this component. + const [enteredAtMs] = useState(() => Date.now()); + + // Single 1s poll while provisioning is live and not yet terminal: checks + // both the 502-grace sub-machine (pure `checkGraceExpired` helper) and the + // overall wall-clock budget, dispatching whichever fires. + const { first502AtMs, gateway502Expired, provisioningTimedOut } = state; useEffect(() => { - if (first502AtMs === null || gateway502Expired) { + if (terminalReason !== null) { return undefined; } const interval = setInterval(() => { - if (checkGraceExpired({ first502AtMs }, Date.now())) { + const nowMs = Date.now(); + if ( + first502AtMs !== null && + !gateway502Expired && + checkGraceExpired({ first502AtMs }, nowMs) + ) { onGraceElapsed(); } + if (!provisioningTimedOut && nowMs - enteredAtMs >= OVERALL_TIMEOUT_MS) { + onProvisioningTimeout(); + } }, 1000); return () => { clearInterval(interval); }; - }, [first502AtMs, gateway502Expired, onGraceElapsed]); + }, [ + terminalReason, + first502AtMs, + gateway502Expired, + provisioningTimedOut, + enteredAtMs, + onGraceElapsed, + onProvisioningTimeout, + ]); // Advance to the done step once the instance + gateway gate holds. Step // saves are dispatched from OnboardingFlow and their apply is guaranteed by @@ -121,8 +162,9 @@ export function ProvisioningStep({ } }, [advance, onComplete]); - if (isProvisioningTerminal(state)) { + if (terminalReason !== null) { const danger = toneColor('danger'); + const content = TERMINAL_CONTENT[terminalReason]; return ( Provisioning - Something stalled + {content.title} - We couldn't finish setting up {botName}. Try again, or email hi@kilo.ai if this - keeps happening. + {content.body(botName)} - + + + + ); } - 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/lib/hooks/use-kiloclaw-queries.ts b/apps/mobile/src/lib/hooks/use-kiloclaw-queries.ts index 53a99e2b66..8b19e29c5b 100644 --- a/apps/mobile/src/lib/hooks/use-kiloclaw-queries.ts +++ b/apps/mobile/src/lib/hooks/use-kiloclaw-queries.ts @@ -1,3 +1,4 @@ +import { type inferRouterOutputs, type RootRouter } from '@kilocode/trpc'; import { useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; @@ -47,23 +48,44 @@ 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: number | BillingPollDecider = 60_000 +) { const trpc = useTRPC(); + const intervalOption = + typeof refetchInterval === 'function' + ? (query: { state: { data?: BillingData } }) => refetchInterval(query.state.data) + : refetchInterval; return useQuery( trpc.kiloclaw.getBillingStatus.queryOptions(undefined, { enabled, - refetchInterval: enabled ? 60_000 : false, + refetchInterval: enabled ? intervalOption : 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; diff --git a/apps/mobile/src/lib/onboarding/index.ts b/apps/mobile/src/lib/onboarding/index.ts index f0bd6b454a..8f418b01bc 100644 --- a/apps/mobile/src/lib/onboarding/index.ts +++ b/apps/mobile/src/lib/onboarding/index.ts @@ -16,7 +16,9 @@ export { } from './machine'; export { + getProvisioningTerminalReason, isProvisioningTerminal, + 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..59d04f9e43 --- /dev/null +++ b/apps/mobile/src/lib/onboarding/machine-provisioning-terminal.test.ts @@ -0,0 +1,43 @@ +/** + * Reducer coverage for the provisioning terminal signals added alongside + * `getProvisioningTerminalReason` (query errors, the overall wall-clock + * timeout, and their reset on retry). 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('provisioning-timeout-elapsed', () => { + it('flips provisioningTimedOut', () => { + const s = reduce(INITIAL_STATE, { type: 'provisioning-timeout-elapsed' }); + expect(s.provisioningTimedOut).toBe(true); + }); +}); + +describe('retry-requested clears terminal signals', () => { + it('clears queryErrored and provisioningTimedOut', () => { + const s = run([ + { type: 'provisioning-query-errored' }, + { type: 'provisioning-timeout-elapsed' }, + { type: 'retry-requested' }, + ]); + expect(s.queryErrored).toBe(false); + expect(s.provisioningTimedOut).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/onboarding/machine.ts b/apps/mobile/src/lib/onboarding/machine.ts index d81d78bf07..bab83053e9 100644 --- a/apps/mobile/src/lib/onboarding/machine.ts +++ b/apps/mobile/src/lib/onboarding/machine.ts @@ -81,6 +81,12 @@ export type OnboardingState = { first502AtMs: number | null; gateway502Expired: boolean; + // Terminal signals fed in from outside the 502-grace sub-machine: a hard + // query error (status/gateway-ready query failed) or the overall + // provisioning timeout (see `./selectors.ts` `getProvisioningTerminalReason`). + queryErrored: boolean; + provisioningTimedOut: boolean; + // Analytics fire-once guards. onboardingEnteredFired: boolean; completionReachedFired: boolean; @@ -106,6 +112,8 @@ export const INITIAL_STATE: OnboardingState = { execPresetSaved: false, first502AtMs: null, gateway502Expired: false, + queryErrored: false, + provisioningTimedOut: false, onboardingEnteredFired: false, completionReachedFired: false, }; @@ -128,6 +136,8 @@ export type OnboardingEvent = nowMs: number; } | { type: 'gateway-grace-elapsed' } + | { type: 'provisioning-query-errored' } + | { type: 'provisioning-timeout-elapsed' } | { type: 'bot-identity-saved' } | { type: 'exec-preset-saved' } @@ -245,6 +255,14 @@ export function reduce(state: OnboardingState, event: OnboardingEvent): Onboardi return { ...state, gateway502Expired: true }; } + case 'provisioning-query-errored': { + return { ...state, queryErrored: true }; + } + + case 'provisioning-timeout-elapsed': { + return { ...state, provisioningTimedOut: true }; + } + case 'provisioning-complete-acknowledged': { return { ...state, step: 'done' }; } @@ -266,6 +284,8 @@ export function reduce(state: OnboardingState, event: OnboardingEvent): Onboardi completionReachedFired: false, first502AtMs: null, gateway502Expired: false, + queryErrored: false, + provisioningTimedOut: false, }; } diff --git a/apps/mobile/src/lib/onboarding/selectors.test.ts b/apps/mobile/src/lib/onboarding/selectors.test.ts index 45eb65335b..6e73788f52 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, @@ -232,4 +233,45 @@ describe('isProvisioningTerminal', () => { }); expect(isProvisioningTerminal(s)).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)).toBe(true); + expect(getProvisioningTerminalReason(s)).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)).toBe(true); + expect(getProvisioningTerminalReason(s)).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)).toBe(false); + expect(getProvisioningTerminalReason(s)).toBeNull(); + }); + + it('is true once the overall provisioning timeout elapses', () => { + const s = reduce(INITIAL_STATE, { type: 'provisioning-timeout-elapsed' }); + expect(isProvisioningTerminal(s)).toBe(true); + expect(getProvisioningTerminalReason(s)).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)).toBe('query_error'); + }); + + it('clears after retry-requested so a fresh attempt is not terminal', () => { + const s = run([ + { type: 'provisioning-query-errored' }, + { type: 'provisioning-timeout-elapsed' }, + { type: 'retry-requested' }, + ]); + expect(isProvisioningTerminal(s)).toBe(false); + }); }); diff --git a/apps/mobile/src/lib/onboarding/selectors.ts b/apps/mobile/src/lib/onboarding/selectors.ts index 28a3a3f528..61c2f565a7 100644 --- a/apps/mobile/src/lib/onboarding/selectors.ts +++ b/apps/mobile/src/lib/onboarding/selectors.ts @@ -89,10 +89,49 @@ 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. + */ +export function getProvisioningTerminalReason( + state: OnboardingState +): 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 (state.provisioningTimedOut) { + 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; + return getProvisioningTerminalReason(state) !== null; } From 0d956c93e9a6e1d8e385f3c01326b25f7beb34f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:13:20 +0200 Subject: [PATCH 054/166] fix(mobile): organization sheet errors and validation --- .../organization/invite-member-sheet.tsx | 51 +++--- .../organization/low-balance-alert-sheet.tsx | 151 +++++++++++------- .../organization/member-limit-sheet.tsx | 83 ++++++---- .../lib/hooks/use-organization-mutations.ts | 68 +++++--- 4 files changed, 218 insertions(+), 135 deletions(-) diff --git a/apps/mobile/src/components/organization/invite-member-sheet.tsx b/apps/mobile/src/components/organization/invite-member-sheet.tsx index b875d1a6c5..5ff09e549c 100644 --- a/apps/mobile/src/components/organization/invite-member-sheet.tsx +++ b/apps/mobile/src/components/organization/invite-member-sheet.tsx @@ -2,11 +2,12 @@ import * as Haptics from 'expo-haptics'; import { useRouter } from 'expo-router'; import { Check } from 'lucide-react-native'; import { useRef, useState } from 'react'; -import { ActivityIndicator, Pressable, ScrollView, TextInput, View } from 'react-native'; +import { Pressable, ScrollView, View } from 'react-native'; import { ROLE_LABEL } from '@/components/organization/member-row'; import { captureEvent, ORGANIZATION_MEMBER_INVITED_EVENT } from '@/lib/analytics/posthog'; import { Button } from '@/components/ui/button'; +import { FormField } from '@/components/ui/form-field'; import { Text } from '@/components/ui/text'; import { useOrganizationMutations } from '@/lib/hooks/use-organization-mutations'; import { type OrgRole, useOrgRole } from '@/lib/hooks/use-organization-queries'; @@ -14,6 +15,7 @@ import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { cn, EMAIL_PATTERN } from '@/lib/utils'; const INVITABLE_ROLES: OrgRole[] = ['member', 'billing_manager', 'owner']; +const EMAIL_ERROR = 'Enter a valid email address'; export function InviteMemberSheet() { const router = useRouter(); @@ -22,12 +24,14 @@ export function InviteMemberSheet() { const mutations = useOrganizationMutations(organizationId ?? ''); const emailRef = useRef(''); const [canSubmit, setCanSubmit] = useState(false); + const [emailError, setEmailError] = useState(null); const isBillingManager = myRole === 'billing_manager'; const [role, setRole] = useState('member'); const onSubmit = () => { const email = emailRef.current.trim().toLowerCase(); if (!EMAIL_PATTERN.test(email)) { + setEmailError(EMAIL_ERROR); return; } mutations.invite.mutate( @@ -53,25 +57,27 @@ export function InviteMemberSheet() { > Invite member - - - Email - - { - emailRef.current = value; - setCanSubmit(EMAIL_PATTERN.test(value.trim())); - }} - /> - + { + emailRef.current = value; + const valid = EMAIL_PATTERN.test(value.trim()); + setCanSubmit(valid); + if (emailError) { + setEmailError(valid ? null : EMAIL_ERROR); + } + }} + onBlur={() => { + setEmailError(EMAIL_PATTERN.test(emailRef.current.trim()) ? null : EMAIL_ERROR); + }} + /> {isBillingManager ? ( Role: Member @@ -109,10 +115,7 @@ export function InviteMemberSheet() { {mutations.invite.error.message} )} - 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..21fb6a58ee 100644 --- a/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx +++ b/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx @@ -1,9 +1,11 @@ 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 { 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'; @@ -13,9 +15,11 @@ import { useOrgRole, useOrgWithMembers, } from '@/lib/hooks/use-organization-queries'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; 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'; + function parseThreshold(value: string): number | null { const trimmed = value.trim(); const parsed = Number(trimmed); @@ -32,6 +36,17 @@ function parseEmails(value: string): string[] { .filter(email => email !== ''); } +function thresholdError(value: string): string | null { + return parseThreshold(value) == null ? THRESHOLD_ERROR : null; +} + +function emailsError(value: string): string | null { + const emails = parseEmails(value); + return emails.length === 0 || !emails.every(email => EMAIL_PATTERN.test(email)) + ? EMAILS_ERROR + : null; +} + type LowBalanceAlertFormProps = Readonly<{ organizationId: string | null; settings: OrgWithMembers['settings']; @@ -39,7 +54,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 +61,23 @@ 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 [thresholdFieldError, setThresholdFieldError] = useState(null); + const [emailsFieldError, setEmailsFieldError] = useState(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,40 +120,44 @@ 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); + if (thresholdFieldError) { + setThresholdFieldError(thresholdError(value)); + } + }} + onBlur={() => { + setThresholdFieldError(thresholdError(thresholdRef.current)); + }} + /> + + + { emailsRef.current = value; revalidate(enabled, thresholdRef.current, value); + if (emailsFieldError) { + setEmailsFieldError(emailsError(value)); + } + }} + onBlur={() => { + setEmailsFieldError(emailsError(emailsRef.current)); }} /> @@ -156,10 +173,11 @@ function LowBalanceAlertForm({ organizationId, settings }: LowBalanceAlertFormPr )} - @@ -170,6 +188,31 @@ export function LowBalanceAlertSheet() { const { organizationId } = useOrgRole(); const orgWithMembers = useOrgWithMembers(organizationId); + let body: ReactNode = null; + if (orgWithMembers.data) { + body = ( + + ); + } else if (orgWithMembers.isError) { + body = ( + void orgWithMembers.refetch()} + isRetrying={orgWithMembers.isFetching} + placement="top" + /> + ); + } else { + body = ( + <> + + + + ); + } + return ( Low balance alert - {orgWithMembers.data ? ( - - ) : ( - <> - - - - )} + {body} ); } diff --git a/apps/mobile/src/components/organization/member-limit-sheet.tsx b/apps/mobile/src/components/organization/member-limit-sheet.tsx index 4276bf8853..5869370f65 100644 --- a/apps/mobile/src/components/organization/member-limit-sheet.tsx +++ b/apps/mobile/src/components/organization/member-limit-sheet.tsx @@ -1,9 +1,11 @@ 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 { 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'; @@ -12,25 +14,27 @@ import { useOrgRole, 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; +const LIMIT_RANGE_ERROR = `Enter an amount between 0 and ${MAX_DAILY_LIMIT_USD}`; -function parseLimit(value: string): number | null { +// A blank field is valid — it means "no limit" (same as pressing Remove limit). +function limitError(value: string): string | 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 LIMIT_RANGE_ERROR; } - return parsed; + return null; } -function isValidLimit(value: string): boolean { - return parseLimit(value) != null; +function parseLimit(value: string): number | null { + const trimmed = value.trim(); + return trimmed === '' ? null : Number(trimmed); } type MemberLimitFormProps = Readonly<{ @@ -41,12 +45,14 @@ 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 [fieldError, setFieldError] = useState(null); const onSaved = () => { void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); @@ -54,10 +60,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 +73,30 @@ function MemberLimitForm({ memberId, organizationId, member }: MemberLimitFormPr return ( <> - - - Limit (USD per day) - - { - limitRef.current = value; - setCanSave(isValidLimit(value)); - }} - /> - + { + limitRef.current = value; + setCanSave(limitError(value) == null); + if (fieldError) { + setFieldError(limitError(value)); + } + }} + onBlur={() => { + setFieldError(limitError(limitRef.current)); + }} + /> {mutations.updateMember.isError && ( {mutations.updateMember.error.message} )} - @@ -129,6 +133,19 @@ export function MemberLimitSheet({ memberId }: Readonly<{ memberId: string }>) { ); } + 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/lib/hooks/use-organization-mutations.ts b/apps/mobile/src/lib/hooks/use-organization-mutations.ts index f6601e37f2..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, }; @@ -100,12 +119,13 @@ export function useOrganizationMutations(organizationId: string) { 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, }), @@ -130,7 +150,8 @@ export function useOrganizationMutations(organizationId: string) { } : member ), - }) + }), + { silent: silenceUpdateMemberToast } ), }), @@ -158,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: { @@ -173,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 } + ), }), }; } From 7c16578936880950939c0a24a72c1960bfd46fd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:16:13 +0200 Subject: [PATCH 055/166] fix(mobile): recoverable model and repo failures on new session --- apps/mobile/src/app/(app)/agent-chat/new.tsx | 142 +++++++++++------- .../src/components/agents/chat-toolbar.tsx | 3 + .../src/components/agents/model-selector.tsx | 8 + .../src/lib/hooks/use-available-models.ts | 4 +- 4 files changed, 100 insertions(+), 57 deletions(-) diff --git a/apps/mobile/src/app/(app)/agent-chat/new.tsx b/apps/mobile/src/app/(app)/agent-chat/new.tsx index d8556d8404..25109aad40 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 { @@ -94,7 +95,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 +119,7 @@ export default function NewSessionScreen() { const { data: repoData, isLoading: isLoadingRepos, + isError: isReposError, isRefetching: isRefetchingRepos, refetch: refetchRepos, } = useQuery( @@ -327,64 +334,89 @@ export default function NewSessionScreen() { autoFocus /> - + {isModelsError ? ( + 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 ? ( + void refetchRepos()} + isRetrying={isRefetchingRepos} + /> + ) : ( + <> + + {showGitHubIntegrationPrompt ? ( + + + Connect GitHub + + Connect GitHub in your browser, then return here to pick a repository. + + + + + + + + ) : null} + + )} - - - - - + + 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 146b57c192..23f343cca7 100644 --- a/apps/mobile/src/components/security-agent/finding-list-screen.tsx +++ b/apps/mobile/src/components/security-agent/finding-list-screen.tsx @@ -6,6 +6,7 @@ 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'; @@ -13,7 +14,6 @@ import { FlatList, Pressable, RefreshControl, View } from 'react-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'; @@ -25,6 +25,8 @@ 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'; @@ -48,10 +50,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); @@ -91,9 +93,15 @@ export function FindingListScreen({ scope, routeParams }: Readonly { - if (!filterUnavailable) { - setShowFilterModal(true); + if (filterUnavailable) { + return; } + setSecurityFindingFilterBridge({ + filters, + repositories: scopedRepositories, + onApply: setFilters, + }); + router.push(getSecurityAgentPath(scope, 'filter')); }} disabled={filterUnavailable} accessibilityRole="button" @@ -180,16 +188,6 @@ export function FindingListScreen({ scope, routeParams }: Readonly )} - {showFilterModal && ( - { - setShowFilterModal(false); - }} - onApply={setFilters} - /> - )} ); } 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..005783e000 --- /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. +export 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; +} From 6b80447eb22113ab115080796e4c30615aedd0de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:17:24 +0200 Subject: [PATCH 059/166] fix(mobile): new-session input gating and attachment rejection feedback --- apps/mobile/src/app/(app)/agent-chat/new.tsx | 12 +++++++++++- .../src/components/agents/attachment-picker.ts | 16 +++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/app/(app)/agent-chat/new.tsx b/apps/mobile/src/app/(app)/agent-chat/new.tsx index 25109aad40..c06d81c152 100644 --- a/apps/mobile/src/app/(app)/agent-chat/new.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/new.tsx @@ -84,6 +84,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, @@ -278,6 +279,13 @@ export default function NewSessionScreen() { setPromptInputWidth(current => (current === nextWidth ? current : nextWidth)); } + const canCreate = + hasPrompt && + Boolean(selectedRepo) && + Boolean(model) && + !attachments.isUploading && + !attachments.hasFailedAttachments; + return ( @@ -327,6 +335,8 @@ 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} @@ -422,7 +432,7 @@ export default function NewSessionScreen() { ); } From d494fa2ddbadfcdb3099b61e84da4413558971b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:18:46 +0200 Subject: [PATCH 061/166] fix(mobile): gate question submission on all answers --- .../src/components/agents/question-card.tsx | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/components/agents/question-card.tsx b/apps/mobile/src/components/agents/question-card.tsx index 7a49c85181..0cfd117460 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,15 +112,7 @@ 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() { @@ -128,11 +122,7 @@ export function QuestionCard({ ]); } - 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 ( @@ -221,12 +211,16 @@ export function QuestionCard({ - From 817464f8bc6e1c14a0b390f8678f98595f132c93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:19:02 +0200 Subject: [PATCH 062/166] fix(mobile): drop unused isProvisioningTerminal barrel export --- apps/mobile/src/lib/onboarding/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/mobile/src/lib/onboarding/index.ts b/apps/mobile/src/lib/onboarding/index.ts index 8f418b01bc..6663fe6f5e 100644 --- a/apps/mobile/src/lib/onboarding/index.ts +++ b/apps/mobile/src/lib/onboarding/index.ts @@ -17,7 +17,6 @@ export { export { getProvisioningTerminalReason, - isProvisioningTerminal, type ProvisioningTerminalReason, shouldAdvanceFromProvisioning, shouldFireCompletion, From 45aff3425dc98d565e5518b3b5ca1a5e3a433717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:19:14 +0200 Subject: [PATCH 063/166] fix(mobile): code-reviewer repos error and empty states --- .../[scope]/[platform]/repos.tsx | 122 ++++++++++++++++-- .../code-reviewer/bitbucket-overview.tsx | 23 +++- apps/mobile/src/lib/integration-urls.test.ts | 13 +- apps/mobile/src/lib/integration-urls.ts | 9 ++ 4 files changed, 153 insertions(+), 14 deletions(-) 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 index 30e567d09e..8859e643ff 100644 --- 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 @@ -1,15 +1,22 @@ import * as Haptics from 'expo-haptics'; import { type Href } from 'expo-router'; -import { Check, Lock } from 'lucide-react-native'; +import { Check, FolderGit2, Lock } from 'lucide-react-native'; import { Pressable, View } from 'react-native'; +import { EmptyState } from '@/components/empty-state'; import { InvalidRouteState } from '@/components/invalid-route-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 { 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, @@ -20,6 +27,7 @@ import { } from '@/lib/hooks/use-code-reviewer'; import { useValidatedReviewerRouteParams } from '@/lib/hooks/use-reviewer-route-params'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { getBitbucketIntegrationUrl, getGitLabIntegrationUrl } from '@/lib/integration-urls'; import { cn } from '@/lib/utils'; export default function ReposRoute() { @@ -58,13 +66,63 @@ function ReposRouteContent({ })) : []; const reposByPlatform = { - github: { isLoading: githubRepos.isLoading, rows: githubRepos.data?.repositories ?? [] }, - gitlab: { isLoading: gitlabRepos.isLoading, rows: gitlabRepos.data?.repositories ?? [] }, - bitbucket: { isLoading: bitbucketReadiness.isLoading, rows: bitbucketRepos }, + 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, rows: repoRows } = reposByPlatform[platform]; + 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') => { void Haptics.selectionAsync(); @@ -120,13 +178,57 @@ function ReposRouteContent({ )} - {!reposLoading && - platform === 'bitbucket' && - bitbucketReadiness.data?.repositoryCache.status !== 'available' && ( - + + {!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 => ( value.charAt(0).toUpperCase() + value.slice(1); @@ -191,9 +195,22 @@ 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 + + + )} 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`; +} From 7882edd2a9f6d6e57c41abd313a446459d005af0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:20:10 +0200 Subject: [PATCH 064/166] fix(mobile): organization route boundary and role guards --- .../organization/credit-activity-screen.tsx | 14 ++- .../components/organization/hub-screen.tsx | 107 +++++++----------- .../organization/invite-member-sheet.tsx | 26 ++++- .../organization/invoices-screen.tsx | 18 ++- .../organization/low-balance-alert-sheet.tsx | 26 ++++- .../organization/member-limit-sheet.tsx | 20 +++- .../organization/members-screen.tsx | 14 ++- .../organization/organization-boundary.tsx | 53 +++++++++ .../organization/permission-denied.tsx | 40 +++++++ .../src/lib/hooks/use-organization-queries.ts | 18 +++ 10 files changed, 252 insertions(+), 84 deletions(-) create mode 100644 apps/mobile/src/components/organization/organization-boundary.tsx create mode 100644 apps/mobile/src/components/organization/permission-denied.tsx diff --git a/apps/mobile/src/components/organization/credit-activity-screen.tsx b/apps/mobile/src/components/organization/credit-activity-screen.tsx index 93e77e0895..cce066c5b5 100644 --- a/apps/mobile/src/components/organization/credit-activity-screen.tsx +++ b/apps/mobile/src/components/organization/credit-activity-screen.tsx @@ -5,6 +5,7 @@ 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'; @@ -12,8 +13,8 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { type CreditTransaction, + useOrgBoundary, useOrgCreditTransactions, - useOrgRole, } from '@/lib/hooks/use-organization-queries'; import { cn, firstNonEmpty, parseTimestamp } from '@/lib/utils'; @@ -66,12 +67,17 @@ function CreditRow({ transaction }: Readonly<{ transaction: CreditTransaction }> } export function OrganizationCreditActivityScreen() { - const { organizationId } = useOrgRole(); + const { organizationId, org, isResolving } = useOrgBoundary(); const query = useOrgCreditTransactions(organizationId); const paddingBottom = useTabBarBottomPadding(); - if (organizationId == null) { - return null; + if (isResolving || organizationId == null || org == null) { + return ( + + + + + ); } const isLoading = query.isLoading; diff --git a/apps/mobile/src/components/organization/hub-screen.tsx b/apps/mobile/src/components/organization/hub-screen.tsx index b0d4ce013e..6b86ccfe87 100644 --- a/apps/mobile/src/components/organization/hub-screen.tsx +++ b/apps/mobile/src/components/organization/hub-screen.tsx @@ -2,42 +2,41 @@ import { formatDollars, fromMicrodollars } from '@kilocode/app-shared/utils'; import * as Haptics from 'expo-haptics'; import { type Href, useRouter } from 'expo-router'; import { Bell, FileText, Pencil, Receipt, Users } from 'lucide-react-native'; -import { type ReactNode, useState } from 'react'; +import { useState } from 'react'; import { Pressable, View } from 'react-native'; -import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; +import Animated, { FadeIn } from 'react-native-reanimated'; +import { OrganizationBoundary } from '@/components/organization/organization-boundary'; import { OrgUsageStats } from '@/components/organization/org-usage-stats'; -import { QueryError } from '@/components/query-error'; import { RenameModal } from '@/components/rename-modal'; import { ScreenHeader } from '@/components/screen-header'; import { ConfigureRow } from '@/components/ui/configure-row'; import { KvRow } from '@/components/ui/kv-row'; -import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { TabScreenScrollView } from '@/components/tab-screen'; import { useOrganizationMutations } from '@/lib/hooks/use-organization-mutations'; -import { isMoneyRole, useOrgRole, useOrgWithMembers } from '@/lib/hooks/use-organization-queries'; +import { + isMoneyRole, + useOrgBoundary, + useOrgWithMembers, +} from '@/lib/hooks/use-organization-queries'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -function InfoCardSkeleton() { - return ( - - - - - ); -} - export function OrganizationHubScreen() { const router = useRouter(); const colors = useThemeColors(); - const { organizationId, role, org, isLoading, isError, isFetching, refetch } = useOrgRole(); + const { organizationId, role, org, isResolving } = useOrgBoundary(); const orgWithMembers = useOrgWithMembers(organizationId); const mutations = useOrganizationMutations(organizationId ?? ''); const [renameVisible, setRenameVisible] = useState(false); - if (organizationId == null) { - return null; + if (isResolving || organizationId == null || org == null) { + return ( + + + + + ); } const showMoney = isMoneyRole(role); @@ -45,60 +44,38 @@ export function OrganizationHubScreen() { const lowBalanceSubtitle = minimumBalance != null ? `Below ${formatDollars(minimumBalance)}` : 'Off'; - let infoCard: ReactNode = null; - if (isLoading) { - infoCard = ( - - - - ); - } else if (isError && !org) { - infoCard = ( - void refetch()} - isRetrying={isFetching} - className="rounded-lg bg-secondary py-6" - placement="top" - /> - ); - } else if (org) { - infoCard = ( - - - - {org.organizationName} - - {showMoney && ( - { - setRenameVisible(true); - }} - hitSlop={12} - accessibilityRole="button" - accessibilityLabel="Rename organization" - className="active:opacity-70" - > - - - )} - - {showMoney && ( - - )} - - - ); - } - return ( - + - {infoCard} + + + + {org.organizationName} + + {showMoney && ( + { + setRenameVisible(true); + }} + hitSlop={12} + accessibilityRole="button" + accessibilityLabel="Rename organization" + className="active:opacity-70" + > + + + )} + + {showMoney && ( + + )} + + @@ -141,7 +118,7 @@ export function OrganizationHubScreen() { - {renameVisible && org && ( + {renameVisible && ( ('member'); + if (isResolving) { + return ( + + + + + ); + } + if (organizationId == null || org == null) { + return ( + + + + ); + } + if (!isMoneyRole(myRole)) { + return ; + } + const onSubmit = () => { const email = emailRef.current.trim().toLowerCase(); if (!EMAIL_PATTERN.test(email)) { diff --git a/apps/mobile/src/components/organization/invoices-screen.tsx b/apps/mobile/src/components/organization/invoices-screen.tsx index 99fb2a883e..d7d9bb4df9 100644 --- a/apps/mobile/src/components/organization/invoices-screen.tsx +++ b/apps/mobile/src/components/organization/invoices-screen.tsx @@ -5,12 +5,17 @@ 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 { + type OrgInvoice, + useOrgBoundary, + useOrgInvoices, +} from '@/lib/hooks/use-organization-queries'; import { cn, firstNonEmpty } from '@/lib/utils'; const STATUS_META: Record = { @@ -65,12 +70,17 @@ 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; 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 21fb6a58ee..73173f2e3a 100644 --- a/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx +++ b/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx @@ -3,6 +3,8 @@ import { useRouter } from 'expo-router'; import { type ReactNode, useRef, useState } from 'react'; import { ScrollView, Switch, View } from 'react-native'; +import { OrganizationBoundary } from '@/components/organization/organization-boundary'; +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'; @@ -11,8 +13,9 @@ 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 { EMAIL_PATTERN } from '@/lib/utils'; @@ -185,9 +188,28 @@ function LowBalanceAlertForm({ organizationId, settings }: LowBalanceAlertFormPr } export function LowBalanceAlertSheet() { - const { organizationId } = useOrgRole(); + const { organizationId, role, org, isResolving } = useOrgBoundary(); const orgWithMembers = useOrgWithMembers(organizationId); + if (isResolving) { + return ( + + + + + ); + } + if (organizationId == null || org == null) { + return ( + + + + ); + } + if (!isMoneyRole(role)) { + return ; + } + let body: ReactNode = null; if (orgWithMembers.data) { body = ( diff --git a/apps/mobile/src/components/organization/member-limit-sheet.tsx b/apps/mobile/src/components/organization/member-limit-sheet.tsx index 5869370f65..a719dda3ec 100644 --- a/apps/mobile/src/components/organization/member-limit-sheet.tsx +++ b/apps/mobile/src/components/organization/member-limit-sheet.tsx @@ -3,6 +3,8 @@ import { useRouter } from 'expo-router'; import { useRef, useState } from 'react'; import { ScrollView, View } from 'react-native'; +import { OrganizationBoundary } from '@/components/organization/organization-boundary'; +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'; @@ -11,7 +13,7 @@ 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 { firstNonEmpty } from '@/lib/utils'; @@ -114,13 +116,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 ( @@ -133,6 +135,18 @@ export function MemberLimitSheet({ memberId }: Readonly<{ memberId: string }>) { ); } + if (organizationId == null || org == null) { + return ( + + + + ); + } + + if (role !== 'owner') { + return ; + } + if (orgWithMembers.isError && !orgWithMembers.data) { return ( diff --git a/apps/mobile/src/components/organization/members-screen.tsx b/apps/mobile/src/components/organization/members-screen.tsx index e10e001b12..01aef2c9c2 100644 --- a/apps/mobile/src/components/organization/members-screen.tsx +++ b/apps/mobile/src/components/organization/members-screen.tsx @@ -6,6 +6,7 @@ import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanim 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 { Skeleton } from '@/components/ui/skeleton'; @@ -16,7 +17,7 @@ import { type ActiveOrgMember, type InvitedOrgMember, isMoneyRole, - useOrgRole, + useOrgBoundary, useOrgWithMembers, } from '@/lib/hooks/use-organization-queries'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; @@ -56,12 +57,17 @@ 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; 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..2992db340e --- /dev/null +++ b/apps/mobile/src/components/organization/organization-boundary.tsx @@ -0,0 +1,53 @@ +import { type Href, useRouter } from 'expo-router'; +import { Building2 } from 'lucide-react-native'; +import { ActivityIndicator, View } from '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'; + +const PROFILE_HREF = '/(app)/(tabs)/(3_profile)' as Href; + +type OrganizationBoundaryProps = Readonly<{ + /** Identity/org-list is still resolving — show a brief in-progress state instead of the empty state. */ + isResolving?: boolean; +}>; + +/** + * Content shown in place of an organization screen or sheet when the + * persisted org selection doesn't resolve to a real membership (stale/ + * deleted org, or no org selected at all) — never renders `null`, so the + * route is never blank. Callers own their own chrome (`ScreenHeader` for + * full screens, nothing for form sheets) — this only renders the content. + */ +export function OrganizationBoundary({ isResolving }: OrganizationBoundaryProps = {}) { + const router = useRouter(); + const colors = useThemeColors(); + + if (isResolving) { + return ( + + + + ); + } + + return ( + { + router.replace(PROFILE_HREF); + }} + > + Back to profile + + } + /> + ); +} 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/lib/hooks/use-organization-queries.ts b/apps/mobile/src/lib/hooks/use-organization-queries.ts index 7b34c25f7e..ac5cde4777 100644 --- a/apps/mobile/src/lib/hooks/use-organization-queries.ts +++ b/apps/mobile/src/lib/hooks/use-organization-queries.ts @@ -33,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 } = useOrgRole(); + const isResolving = !isLoaded || isLoading; + return { organizationId, role, org, isResolving }; +} + export function useOrgWithMembers(organizationId: string | null) { const trpc = useTRPC(); return useQuery( From 68d9887ff16fa79fd3dad6fd406818b4d5466c9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:22:00 +0200 Subject: [PATCH 065/166] fix(mobile): remediation cancel state and terminology --- .../finding-remediation-panel.tsx | 10 ++- .../components/security-agent/finding-row.tsx | 4 +- .../src/lib/hooks/use-security-findings.ts | 77 +++++++++++-------- .../src/lib/security-finding-filter-bridge.ts | 2 +- 4 files changed, 54 insertions(+), 39 deletions(-) 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 8c82d76bdf..57fb117728 100644 --- a/apps/mobile/src/components/security-agent/finding-remediation-panel.tsx +++ b/apps/mobile/src/components/security-agent/finding-remediation-panel.tsx @@ -131,7 +131,7 @@ export function FindingRemediationPanel({ {startRemediation.isPending ? ( ) : null} - Start fix + Start remediation ) : null} @@ -146,7 +146,7 @@ export function FindingRemediationPanel({ {retryRemediation.isPending ? ( ) : null} - Retry fix + Retry remediation ) : null} @@ -178,7 +178,7 @@ export function FindingRemediationPanel({ {cancelRemediation.isPending ? ( ) : null} - Cancel fix + Cancel remediation ) : null} @@ -200,6 +200,10 @@ export function FindingRemediationPanel({ {remediationAttempts.map(attempt => { diff --git a/apps/mobile/src/components/security-agent/finding-row.tsx b/apps/mobile/src/components/security-agent/finding-row.tsx index 5acb5cf3a4..56b8c92593 100644 --- a/apps/mobile/src/components/security-agent/finding-row.tsx +++ b/apps/mobile/src/components/security-agent/finding-row.tsx @@ -45,10 +45,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'); diff --git a/apps/mobile/src/lib/hooks/use-security-findings.ts b/apps/mobile/src/lib/hooks/use-security-findings.ts index 39802e89ff..d966b6d34b 100644 --- a/apps/mobile/src/lib/hooks/use-security-findings.ts +++ b/apps/mobile/src/lib/hooks/use-security-findings.ts @@ -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,38 @@ 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: () => { + toast.success('Remediation cancelled'); + }, + onSettled: async (_result, _error, vars) => { + await invalidateRemediationQueries( + { trpc, queryClient }, + { scope, findingId: vars.findingId } + ); }, }); } diff --git a/apps/mobile/src/lib/security-finding-filter-bridge.ts b/apps/mobile/src/lib/security-finding-filter-bridge.ts index 005783e000..0194795721 100644 --- a/apps/mobile/src/lib/security-finding-filter-bridge.ts +++ b/apps/mobile/src/lib/security-finding-filter-bridge.ts @@ -4,7 +4,7 @@ import { type SecurityFindingFilters } from '@kilocode/app-shared/security-agent // 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. -export type SecurityFindingFilterRepositoryOption = { +type SecurityFindingFilterRepositoryOption = { fullName: string; }; From 3416b0ff65c4aebaf24d86cd496555904019617c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:22:20 +0200 Subject: [PATCH 066/166] fix(mobile): organization empty-state actions --- .../organization/credit-activity-screen.tsx | 2 +- .../components/organization/hub-screen.tsx | 24 ++++++++++++++++ .../organization/invoices-screen.tsx | 2 +- .../organization/members-screen.tsx | 28 ++++++++++++++++++- 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/components/organization/credit-activity-screen.tsx b/apps/mobile/src/components/organization/credit-activity-screen.tsx index cce066c5b5..cabdad3a54 100644 --- a/apps/mobile/src/components/organization/credit-activity-screen.tsx +++ b/apps/mobile/src/components/organization/credit-activity-screen.tsx @@ -115,7 +115,7 @@ export function OrganizationCreditActivityScreen() { icon={Receipt} className="pt-16" title="No credit activity" - description="Credit transactions for this organization will show up here." + description="Purchases, usage, and credit adjustments for this organization will appear here as they happen." /> } /> diff --git a/apps/mobile/src/components/organization/hub-screen.tsx b/apps/mobile/src/components/organization/hub-screen.tsx index 6b86ccfe87..0dc1d2cc7d 100644 --- a/apps/mobile/src/components/organization/hub-screen.tsx +++ b/apps/mobile/src/components/organization/hub-screen.tsx @@ -10,10 +10,13 @@ import { OrganizationBoundary } from '@/components/organization/organization-bou import { OrgUsageStats } from '@/components/organization/org-usage-stats'; import { RenameModal } from '@/components/rename-modal'; import { ScreenHeader } from '@/components/screen-header'; +import { Button } from '@/components/ui/button'; import { ConfigureRow } from '@/components/ui/configure-row'; import { KvRow } from '@/components/ui/kv-row'; import { Text } from '@/components/ui/text'; import { TabScreenScrollView } from '@/components/tab-screen'; +import { WEB_BASE_URL } from '@/lib/config'; +import { openExternalUrl } from '@/lib/external-link'; import { useOrganizationMutations } from '@/lib/hooks/use-organization-mutations'; import { isMoneyRole, @@ -74,6 +77,27 @@ export function OrganizationHubScreen() { {showMoney && ( )} + {showMoney && org.balance === 0 && ( + + + Add credits to keep usage running. + + + + )} diff --git a/apps/mobile/src/components/organization/invoices-screen.tsx b/apps/mobile/src/components/organization/invoices-screen.tsx index d7d9bb4df9..0de7516048 100644 --- a/apps/mobile/src/components/organization/invoices-screen.tsx +++ b/apps/mobile/src/components/organization/invoices-screen.tsx @@ -118,7 +118,7 @@ export function OrganizationInvoicesScreen() { icon={FileText} className="pt-16" title="No invoices" - description="Invoices for this organization will show up here." + description="Invoices are generated automatically each billing cycle and will appear here once your organization is billed." /> } /> diff --git a/apps/mobile/src/components/organization/members-screen.tsx b/apps/mobile/src/components/organization/members-screen.tsx index 01aef2c9c2..26cce117ba 100644 --- a/apps/mobile/src/components/organization/members-screen.tsx +++ b/apps/mobile/src/components/organization/members-screen.tsx @@ -1,14 +1,16 @@ import { type Href, useRouter } from 'expo-router'; -import { UserPlus } from 'lucide-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'; @@ -100,6 +102,30 @@ export function OrganizationMembersScreen() { placement="top" /> ); + } else if (activeMembers.length === 0) { + membersBody = ( + { + router.push('/(app)/(tabs)/(3_profile)/organization/invite-member' as Href); + }} + > + Invite member + + ) : undefined + } + /> + ); } else { membersBody = ( From d333a06c97f9a7242ac4872a439566273894d895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:24:32 +0200 Subject: [PATCH 067/166] fix(mobile): session mapper tests and stale-data-preserving list errors --- .../agents/session-list-content.tsx | 12 +- .../src/lib/hooks/use-session-mutations.ts | 37 +---- .../mobile/src/lib/session-list-cache.test.ts | 143 ++++++++++++++++++ apps/mobile/src/lib/session-list-cache.ts | 33 ++++ 4 files changed, 191 insertions(+), 34 deletions(-) create mode 100644 apps/mobile/src/lib/session-list-cache.test.ts create mode 100644 apps/mobile/src/lib/session-list-cache.ts diff --git a/apps/mobile/src/components/agents/session-list-content.tsx b/apps/mobile/src/components/agents/session-list-content.tsx index f4fe496e40..c228cc1ca9 100644 --- a/apps/mobile/src/components/agents/session-list-content.tsx +++ b/apps/mobile/src/components/agents/session-list-content.tsx @@ -109,9 +109,14 @@ export function AgentSessionListContent({ ) : null} + {isError ? ( + + Could not refresh — showing the last saved list. + + ) : null} ), - [colors.mutedForeground, isSearchPending, onSearchChange] + [colors.mutedForeground, isError, isSearchPending, onSearchChange] ); const emptyStateAction = useMemo( @@ -231,7 +236,10 @@ export function AgentSessionListContent({ ); } - 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 ( diff --git a/apps/mobile/src/lib/hooks/use-session-mutations.ts b/apps/mobile/src/lib/hooks/use-session-mutations.ts index 69200189dc..7415009d2a 100644 --- a/apps/mobile/src/lib/hooks/use-session-mutations.ts +++ b/apps/mobile/src/lib/hooks/use-session-mutations.ts @@ -1,48 +1,21 @@ -import { type inferRouterOutputs, type RootRouter } from '@kilocode/trpc'; import { type QueryKey, useMutation, useQueryClient } from '@tanstack/react-query'; import { useRef } from 'react'; import { toast } from 'sonner-native'; import { invalidateAgentSessionQueries } from '@/lib/agent-session-cache'; +import { + mapStoredSessions, + removeStoredSession, + type SessionsListData, +} from '@/lib/session-list-cache'; import { useTRPC } from '@/lib/trpc'; -type RouterOutputs = inferRouterOutputs; -type SessionsListPage = RouterOutputs['cliSessionsV2']['list']; -type SessionsListData = { pages: SessionsListPage[]; pageParams: unknown[] }; type SessionsListSnapshot = [QueryKey, SessionsListData | undefined][]; const onError = (error: { message: string }) => { toast.error(error.message || 'Something went wrong'); }; -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 - ), - })), - }; -} - -function removeStoredSession(data: SessionsListData, sessionId: string): SessionsListData { - return { - ...data, - pages: data.pages.map(page => ({ - ...page, - cliSessions: page.cliSessions.filter(session => session.session_id !== sessionId), - })), - }; -} - export function useSessionMutations() { const trpc = useTRPC(); const queryClient = useQueryClient(); 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), + })), + }; +} From 7f99f8cd5b3a197c715385108d655923c729b44c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:26:54 +0200 Subject: [PATCH 068/166] fix(mobile): kiloclaw lifecycle control states --- .../src/components/kiloclaw/instance-card.tsx | 6 ++- .../components/kiloclaw/instance-controls.tsx | 52 +++++++++++++++---- .../src/components/ui/action-button.tsx | 17 ++++-- 3 files changed, 59 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/components/kiloclaw/instance-card.tsx b/apps/mobile/src/components/kiloclaw/instance-card.tsx index ca6aaa587b..85e144eb5f 100644 --- a/apps/mobile/src/components/kiloclaw/instance-card.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-card.tsx @@ -113,14 +113,16 @@ export function KiloClawCard({ }; return ( - + ; }; +// Statuses where the backend is already mid-transition — starting or +// redeploying now would race an in-flight lifecycle change. Anything NOT in +// these sets (including 'stopped', 'provisioned', 'crashed', and any +// unrecognized/null status) is fair game. +const START_BLOCKING_STATUSES = new Set([ + 'running', + 'starting', + 'restarting', + 'stopping', + 'shutting_down', + 'destroying', +]); +const REDEPLOY_BLOCKING_STATUSES = new Set([ + 'starting', + 'restarting', + 'stopping', + 'shutting_down', + 'destroying', +]); + 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 = status == null || !REDEPLOY_BLOCKING_STATUSES.has(status); + + // Only one lifecycle mutation should ever be in flight at a time — while + // any of these is pending, disable the rest so they can't race each other. + const isLifecycleBusy = + mutations.start.isPending || + mutations.stop.isPending || + mutations.restartOpenClaw.isPending || + mutations.restartMachine.isPending; const handleStart = () => { Alert.alert('Start Instance', 'Are you sure you want to start this instance?', [ @@ -74,32 +102,36 @@ export function InstanceControls({ status, mutations }: Readonly 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} From 5a2e05f9ee7d2c20112e7d3024fec8cd194d3381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:27:20 +0200 Subject: [PATCH 069/166] fix(mobile): destroy instance waits for confirmation --- .../app/(app)/kiloclaw/[instance-id]/dashboard.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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 17d461b497..33b4c916db 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx @@ -158,9 +158,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); + }, + }); }, }, ] From 1bc03b1a1eda310fb0629af35891f02679ef86c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:30:20 +0200 Subject: [PATCH 070/166] fix(mobile): gate auto-review enablement on readiness --- .../code-reviewer/bitbucket-overview.tsx | 66 ++++++++++++++----- .../platform-overview-screen.tsx | 63 +++++++++++++----- 2 files changed, 97 insertions(+), 32 deletions(-) diff --git a/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx b/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx index 5032769fd0..fb6ef035d2 100644 --- a/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx +++ b/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx @@ -101,6 +101,14 @@ export function BitbucketOverview({ }; 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 @@ -174,8 +182,12 @@ export function BitbucketOverview({ {isLoading && ( - - + + + {Array.from({ length: 6 }, (_, index) => ( + + ))} + )} @@ -213,21 +225,43 @@ export function BitbucketOverview({ )} - - - 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. + + + + )} 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 85fc6fb399..29b9d4326a 100644 --- a/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx @@ -145,6 +145,13 @@ export function PlatformOverviewScreen({ }; 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 @@ -184,8 +191,12 @@ export function PlatformOverviewScreen({ {isLoading && ( - - + + + {Array.from({ length: 6 }, (_, index) => ( + + ))} + )} @@ -234,21 +245,41 @@ export function PlatformOverviewScreen({ )} - - - Automatic reviews - - {status.data?.integration?.accountLogin ?? ''} - + + + + Automatic reviews + + {status.data?.integration?.accountLogin ?? ''} + + + { + void Haptics.selectionAsync(); + toggle.mutate({ isEnabled: value }); + }} + /> - { - void Haptics.selectionAsync(); - toggle.mutate({ isEnabled: value }); - }} - /> + {!hasRepoSelection && ( + + + Select at least one repository to enable automatic reviews. + + + + )} From d65c2c8623f1e4511449ea70495be5218eac8870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:31:00 +0200 Subject: [PATCH 071/166] fix(mobile): profile org-failure and zero-balance recovery --- .../src/components/profile-credits-card.tsx | 34 +++++++++++++++- apps/mobile/src/components/profile-screen.tsx | 40 +++++++++++++------ 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/components/profile-credits-card.tsx b/apps/mobile/src/components/profile-credits-card.tsx index e840f1c4e5..36abbaf5fe 100644 --- a/apps/mobile/src/components/profile-credits-card.tsx +++ b/apps/mobile/src/components/profile-credits-card.tsx @@ -2,13 +2,16 @@ 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 { KiloPassSubscriptionCard } from '@/components/kilo-pass/kilo-pass-subscription-card'; +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 { useOrganization } from '@/lib/organization-context'; import { useTRPC } from '@/lib/trpc'; @@ -70,6 +73,15 @@ 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), matching the always-on CTA on the organization hub screen. + const canShowZeroBalanceCta = selectedOrgId != null || 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; @@ -152,6 +164,26 @@ export function CreditsCard({ enabled, orgs }: Readonly) { {balanceFetching && } )} + {!balanceLoading && !balanceError && balanceDollars === 0 && ( + + + {canShowZeroBalanceCta + ? 'Add credits to keep usage running.' + : "Add credits from a browser to keep using Kilo — you can't purchase credits in the app."} + + {canShowZeroBalanceCta && ( + + )} + + )} {enabled && !selectedOrgId ? : null} ); diff --git a/apps/mobile/src/components/profile-screen.tsx b/apps/mobile/src/components/profile-screen.tsx index c7864f4e24..84f69d9603 100644 --- a/apps/mobile/src/components/profile-screen.tsx +++ b/apps/mobile/src/components/profile-screen.tsx @@ -56,7 +56,12 @@ export function ProfileScreen() { ...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, }); @@ -166,17 +171,28 @@ 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); + }} + /> + )} )} From 864c12971cd9fb0c783708caa6c83ba2984df9bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:31:04 +0200 Subject: [PATCH 072/166] fix(mobile): manual review provider path and validation --- .../code-reviewer/manual-review-screen.tsx | 121 ++++++++++++------ 1 file changed, 80 insertions(+), 41 deletions(-) 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 f43f48a9e6..fd959eb72b 100644 --- a/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx @@ -1,14 +1,15 @@ 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, TextInput, View } from 'react-native'; -import { toast } from 'sonner-native'; import { matchesCodeReviewUrlSuffix } from '@kilocode/app-shared/code-review'; import { ModelSelector } from '@/components/agents/model-selector'; +import { EmptyState } from '@/components/empty-state'; 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'; @@ -57,11 +58,13 @@ 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 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); @@ -77,9 +80,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; } @@ -102,6 +106,28 @@ export function ManualReviewScreen({ scope }: Readonly<{ scope: string }>) { ); }; + if (!statusesLoading && !isConnected('github') && !isConnected('gitlab')) { + return ( + + + { + router.push(`/(app)/(tabs)/(3_profile)/code-reviewer/${scope}/github` as Href); + }} + > + Connect GitHub + + } + /> + + ); + } + return ( @@ -115,43 +141,51 @@ 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 + + )} + + + + ); + })} + + )} @@ -168,8 +202,12 @@ export function ManualReviewScreen({ scope }: Readonly<{ scope: string }>) { keyboardType="url" onChangeText={value => { urlRef.current = value; + if (urlError) { + setUrlError(null); + } }} /> + {urlError ? {urlError} : null} @@ -206,10 +244,11 @@ export function ManualReviewScreen({ scope }: Readonly<{ scope: string }>) { From 762bdb79406d069e6e1ec0f5875f046831892b13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:31:50 +0200 Subject: [PATCH 073/166] fix(mobile): session detail recovery and loading structure --- .../src/app/(app)/agent-chat/[session-id].tsx | 12 ++- .../src/components/agents/message-bubble.tsx | 16 +++- .../agents/mobile-session-manager.ts | 36 +++----- .../agents/session-detail-content.tsx | 92 +++++++++++++------ 4 files changed, 103 insertions(+), 53 deletions(-) diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx index 3ca9d314d4..7aef5a255a 100644 --- a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx +++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx @@ -1,10 +1,13 @@ import { type KiloSessionId } from 'cloud-agent-sdk'; import { useLocalSearchParams } from 'expo-router'; import { useQuery } from '@tanstack/react-query'; -import { ActivityIndicator, View } from 'react-native'; +import { View } from 'react-native'; +import { + SessionDetailContent, + SessionSkeletonMessages, +} from '@/components/agents/session-detail-content'; import { AgentSessionProvider } from '@/components/agents/session-provider'; -import { SessionDetailContent } from '@/components/agents/session-detail-content'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; import { useTRPC } from '@/lib/trpc'; @@ -32,8 +35,9 @@ export default function SessionDetailScreen() { if (routeOrganizationId === undefined && sessionQuery.isPending) { return ( - - + + + ); } 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/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/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index c819b3254f..6b11a34dde 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -1,8 +1,9 @@ /* eslint-disable max-lines -- Session orchestration and its render paths are kept together. */ import { type CloudStatus, type KiloSessionId, type StoredMessage } from 'cloud-agent-sdk'; +import { type Href, useRouter } from 'expo-router'; import { useAtomValue } from 'jotai'; import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { ActivityIndicator, FlatList, KeyboardAvoidingView, Platform, View } from 'react-native'; +import { FlatList, KeyboardAvoidingView, Platform, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { toast } from 'sonner-native'; @@ -24,7 +25,10 @@ import { useInteractionHandlers } from '@/components/agents/use-interaction-hand import { useSessionAutoScroll } from '@/components/agents/use-session-auto-scroll'; import { useSessionConfigSync } from '@/components/agents/use-session-config-sync'; import { WorkingIndicator } from '@/components/agents/working-indicator'; +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 { type AgentAttachmentWire } from '@/lib/agent-attachments/use-agent-attachment-upload'; import { @@ -64,6 +68,7 @@ export function SessionDetailContent({ openedVia = 'app', }: Readonly) { const manager = useSessionManager(); + const router = useRouter(); const messages = useAtomValue(manager.atoms.messagesList); const isLoading = useAtomValue(manager.atoms.isLoading); @@ -242,6 +247,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 +302,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,23 +325,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 (sendError) { - toast.error('Failed to send message. Please try again.'); - // Rethrow so the composer knows the send failed and preserves the draft. - throw sendError; + // 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, @@ -439,10 +450,24 @@ export function SessionDetailContent({ function renderContent() { if (shouldBlockMessages) { + return ; + } + if (error && messages.length === 0) { return ( - - - Loading session… + + { + void manager.switchSession(sessionId); + }} + /> + ); } @@ -451,11 +476,7 @@ export function SessionDetailContent({ {statusIndicator ? : null} {emptyStateText ? ( - - {emptyStateText} - + {emptyStateText} ) : null} ); @@ -487,3 +508,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 ( + + + + + + + + + + + + ); +} From b385dc57acb799a1fd461dbd0fd85349b6c55433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:32:19 +0200 Subject: [PATCH 074/166] fix(mobile): billing final-term state and skeleton --- .../(app)/kiloclaw/[instance-id]/billing.tsx | 142 +++++++++++++++--- 1 file changed, 120 insertions(+), 22 deletions(-) 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 8bd07cf017..86ce0ad925 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,12 @@ -import { ExternalLink } from 'lucide-react-native'; +import { formatDollars, fromMicrodollars } from '@kilocode/app-shared/utils'; +import { useMutation } 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'; @@ -15,6 +19,7 @@ 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({ @@ -32,32 +37,107 @@ 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, + onSuccess, +}: Readonly<{ instanceId: string; onSuccess: () => void }>) { + const trpc = useTRPC(); + const mutation = useMutation( + trpc.kiloclaw.continueCommitAsStandard.mutationOptions({ + onSuccess, + onError: error => { + toast.error(error.message); + }, + }) + ); + + return ( + + ); +} + +function FinalCommitTermDetails({ + billing, + onContinued, +}: Readonly<{ + billing: NonNullable['data']>; + onContinued: () => void; +}>) { + 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, + onContinued, }: Readonly<{ billing: NonNullable['data']>; + onContinued: () => void; }>) { + 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 && ( - <> - - - - )} ); } @@ -86,11 +166,13 @@ function PlanDetails({ ); } return ( - - - No active plan - - + ); } @@ -130,9 +212,20 @@ export default function BillingScreen() { return ( - - - + + + + + + + + + + + + + + @@ -166,7 +259,12 @@ export default function BillingScreen() { {/* Plan details */} - + { + void billingQuery.refetch(); + }} + /> {/* Manage billing button */} From 9ed7517a9ef6d6ceeedca3903185ea77efdf336f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:32:41 +0200 Subject: [PATCH 075/166] fix(mobile): notification toggle failure feedback --- .../src/components/notifications-card.tsx | 138 ++++++++++++++---- 1 file changed, 109 insertions(+), 29 deletions(-) diff --git a/apps/mobile/src/components/notifications-card.tsx b/apps/mobile/src/components/notifications-card.tsx index 8daa373f66..310c22cde4 100644 --- a/apps/mobile/src/components/notifications-card.tsx +++ b/apps/mobile/src/components/notifications-card.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Bell, MessageSquare } from 'lucide-react-native'; -import { useCallback, useEffect, useRef } from 'react'; -import { Alert, Linking, Switch, View } from 'react-native'; +import { Bell, MessageSquare, RefreshCw } from 'lucide-react-native'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ActivityIndicator, Alert, Linking, Pressable, Switch, View } from 'react-native'; import { toast } from 'sonner-native'; import { Skeleton } from '@/components/ui/skeleton'; @@ -29,7 +29,14 @@ export function NotificationsCard() { const { token: authToken } = useAuth(); const isAuthenticated = authToken != null; - const { data: permissionGranted = false, isLoading: permissionLoading } = useQuery({ + const [isTogglingPermission, setIsTogglingPermission] = useState(false); + + const { + data: permissionGranted = false, + isLoading: permissionLoading, + isError: permissionError, + refetch: refetchPermission, + } = useQuery({ queryKey: permissionQueryKey, queryFn: async () => { const status = await getNotificationPermissionStatus(); @@ -37,16 +44,27 @@ export function NotificationsCard() { }, }); - const { data: deviceToken, isLoading: deviceTokenLoading } = useQuery({ + const { + data: deviceToken, + isLoading: deviceTokenLoading, + isError: deviceTokenError, + refetch: refetchDeviceToken, + } = useQuery({ queryKey: deviceTokenQueryKey, queryFn: getDevicePushToken, enabled: permissionGranted, }); - const { data: pushTokens, isLoading: tokensLoading } = useQuery({ + const { + data: pushTokens, + isLoading: tokensLoading, + isError: pushTokensError, + refetch: refetchPushTokens, + } = useQuery({ ...trpc.user.getMyPushTokens.queryOptions(), enabled: isAuthenticated, }); + const chatTokensError = deviceTokenError || pushTokensError; const pushTokensQueryKey = trpc.user.getMyPushTokens.queryOptions().queryKey; const serverRegistered = @@ -103,6 +121,8 @@ export function NotificationsCard() { }) ); + const chatTogglePending = registerToken.isPending || unregisterToken.isPending; + // Re-check permission on foreground resume const { isActive } = useAppLifecycle(); const wasActiveRef = useRef(isActive); @@ -128,8 +148,17 @@ export function NotificationsCard() { ); return; } - await Notifications.requestPermissionsAsync(); - void queryClient.invalidateQueries({ queryKey: permissionQueryKey }); + setIsTogglingPermission(true); + try { + await Notifications.requestPermissionsAsync(); + void queryClient.invalidateQueries({ queryKey: permissionQueryKey }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Failed to request notification permission.' + ); + } finally { + setIsTogglingPermission(false); + } } else { Alert.alert( 'Disable Notifications', @@ -147,10 +176,22 @@ export function NotificationsCard() { const handleToggleChatMessages = useCallback( async (value: boolean) => { if (value) { - const token = await registerForPushNotifications(); - if (token) { - registerToken.mutate({ token, platform: getPlatform() }); + let token: string | null = null; + try { + token = await registerForPushNotifications(); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : 'Registration failed. Check your notification permissions.' + ); + return; } + if (!token) { + toast.error('Registration failed. Check your notification permissions.'); + return; + } + registerToken.mutate({ token, platform: getPlatform() }); } else if (deviceToken) { unregisterToken.mutate({ token: deviceToken }); } @@ -168,13 +209,30 @@ export function NotificationsCard() { Notifications - {permissionLoading ? ( - - ) : ( - void handleToggleNotifications(value)} - /> + {permissionLoading && } + {!permissionLoading && permissionError && ( + void refetchPermission()} + accessibilityRole="button" + accessibilityLabel="Retry checking notification permission" + > + + Retry + + )} + {!permissionLoading && !permissionError && ( + <> + {isTogglingPermission && ( + + )} + void handleToggleNotifications(value)} + /> + )} @@ -184,19 +242,41 @@ export function NotificationsCard() { > Chat Messages - {permissionLoading || tokensLoading || deviceTokenLoading ? ( + {(permissionLoading || tokensLoading || deviceTokenLoading) && ( - ) : ( - { - if (registerToken.isPending || unregisterToken.isPending) { - return; - } - void handleToggleChatMessages(value); + )} + {!permissionLoading && !tokensLoading && !deviceTokenLoading && chatTokensError && ( + { + void refetchDeviceToken(); + void refetchPushTokens(); }} - /> + accessibilityRole="button" + accessibilityLabel="Retry loading chat message notification status" + > + + Retry + + )} + {!permissionLoading && !tokensLoading && !deviceTokenLoading && !chatTokensError && ( + <> + {chatTogglePending && } + { + if (chatTogglePending) { + return; + } + void handleToggleChatMessages(value); + }} + /> + )} From bfc52b1b020a5b1478896c711c1b7c7ebdf033ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:33:17 +0200 Subject: [PATCH 076/166] fix(mobile): forced-update store fallbacks --- apps/mobile/src/app/force-update.tsx | 29 +------- .../src/components/force-update-screen.tsx | 70 +++++++++++++++++++ 2 files changed, 71 insertions(+), 28 deletions(-) create mode 100644 apps/mobile/src/components/force-update-screen.tsx 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/force-update-screen.tsx b/apps/mobile/src/components/force-update-screen.tsx new file mode 100644 index 0000000000..305383fe3a --- /dev/null +++ b/apps/mobile/src/components/force-update-screen.tsx @@ -0,0 +1,70 @@ +import * as Clipboard from 'expo-clipboard'; +import { Download } from 'lucide-react-native'; +import { useState } from 'react'; +import { Linking, Platform, View } from 'react-native'; +import { toast } from 'sonner-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); + } + }; + + const copyStoreUrl = async () => { + await Clipboard.setStringAsync(STORE_URL); + toast.success('Store link copied'); + }; + + return ( + + + Update Required + + A new version of Kilo is available. Please update to continue. + + + + {storeOpenFailed && ( + + + Could not open the app store. + + + + + + )} + + ); +} From 94bb8cc2879ac46f902d4a4859eb963bbd1c0a6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:33:59 +0200 Subject: [PATCH 077/166] fix(mobile): picker fallback headers and synchronous bridge init --- .../src/app/(app)/agent-chat/mode-picker.tsx | 42 +++++++++---------- .../src/app/(app)/agent-chat/repo-picker.tsx | 11 +++-- .../agents/model-picker-content.tsx | 11 +++-- 3 files changed, 35 insertions(+), 29 deletions(-) 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 c90c9d4958..6740ea8045 100644 --- a/apps/mobile/src/app/(app)/agent-chat/mode-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/mode-picker.tsx @@ -1,11 +1,12 @@ import * as Haptics from 'expo-haptics'; import { useRouter } from 'expo-router'; -import { Check } from 'lucide-react-native'; +import { Check, Info } from 'lucide-react-native'; import { useEffect, useState } from 'react'; 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 { EmptyState } from '@/components/empty-state'; import { SheetHeader } from '@/components/sheet-header'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; @@ -14,26 +15,25 @@ 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 - - + ); } + const currentValue = bridge.currentValue; + function renderItem({ item }: { item: ModeOption }) { const Icon = getModeIcon(item.value); const selected = item.value === currentValue; @@ -67,9 +69,7 @@ export default function ModePickerScreen() { {item.label} - - {item.description} - + {item.description} {selected && } 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 7c2f282f3e..780b2eba21 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,11 @@ 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, 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'; @@ -61,9 +62,11 @@ export default function RepoPickerScreen() { onDone={closePicker} scrollable={false} fallback={ - - No repositories available - + } /> ); diff --git a/apps/mobile/src/components/agents/model-picker-content.tsx b/apps/mobile/src/components/agents/model-picker-content.tsx index fc88257b74..14d9726419 100644 --- a/apps/mobile/src/components/agents/model-picker-content.tsx +++ b/apps/mobile/src/components/agents/model-picker-content.tsx @@ -1,11 +1,12 @@ import * as Haptics from 'expo-haptics'; import { useFocusEffect, useRouter } from 'expo-router'; -import { Search } from 'lucide-react-native'; +import { Info, Search } from 'lucide-react-native'; import { useCallback, useMemo, useRef, useState } from 'react'; import { FlatList, TextInput, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ModelPickerOptionRow } from '@/components/agents/model-selector'; +import { EmptyState } from '@/components/empty-state'; import { PickerSheet } from '@/components/picker-sheet'; import { Text } from '@/components/ui/text'; import { useModelPreferences } from '@/lib/hooks/use-model-preferences'; @@ -139,9 +140,11 @@ export function ModelPickerContent() { onDone={closePicker} scrollable={false} fallback={ - - No models available - + } /> ); From 4848b1137266193545bf3dac1d3abe71f5d0ccd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:34:29 +0200 Subject: [PATCH 078/166] fix(mobile): code-reviewer saves await outcomes without racing --- .../code-reviewer/[scope]/[platform]/gate.tsx | 5 +- .../[scope]/[platform]/style.tsx | 5 +- .../components/code-reviewer/option-list.tsx | 55 ++++++--- .../mobile/src/lib/hooks/use-code-reviewer.ts | 108 ++++++++++++------ 4 files changed, 115 insertions(+), 58 deletions(-) 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]/gate.tsx index e39248d8eb..f94d0feb46 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]/gate.tsx @@ -45,9 +45,8 @@ function GateThresholdRouteContent({ selected={data?.gateThreshold} descriptions={DESCRIPTIONS} disabled={data == null} - onSelect={value => { - save.mutate({ gateThreshold: value }); - }} + // 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]/style.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/style.tsx index 146d7c4087..5be0d9c35b 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]/style.tsx @@ -45,9 +45,8 @@ function ReviewStyleRouteContent({ selected={data?.reviewStyle} descriptions={DESCRIPTIONS} disabled={data == null} - onSelect={value => { - save.mutate({ reviewStyle: value }); - }} + // 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/components/code-reviewer/option-list.tsx b/apps/mobile/src/components/code-reviewer/option-list.tsx index 4a77516bfd..eafcc323a6 100644 --- a/apps/mobile/src/components/code-reviewer/option-list.tsx +++ b/apps/mobile/src/components/code-reviewer/option-list.tsx @@ -1,22 +1,26 @@ import { useRouter } from 'expo-router'; -import { View } from 'react-native'; +import { useState } from 'react'; +import { ActivityIndicator, View } from 'react-native'; import { ScreenHeader } from '@/components/screen-header'; 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, @@ -26,24 +30,45 @@ export function OptionList({ 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 => ( - { - onSelect(option); - router.back(); - }} - className="border-b-[0.5px] border-hair-soft" - /> + + { + void handleSelect(option); + }} + className="border-b-[0.5px] border-hair-soft" + /> + {pending === option && ( + + + + )} + ))} diff --git a/apps/mobile/src/lib/hooks/use-code-reviewer.ts b/apps/mobile/src/lib/hooks/use-code-reviewer.ts index d5981eecc9..0bcff4aeb7 100644 --- a/apps/mobile/src/lib/hooks/use-code-reviewer.ts +++ b/apps/mobile/src/lib/hooks/use-code-reviewer.ts @@ -16,6 +16,31 @@ function isPersonal(scope: string) { return scope === PERSONAL_SCOPE; } +// In-flight save chains keyed 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. Module-level +// (not per-hook-instance) so it holds across remounts of the same screen. +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). + } +} + +async function chainSave(key: string, run: () => Promise): Promise { + const previous = inFlightSaves.get(key); + if (previous) { + await awaitSettled(previous); + } + const next = run(); + inFlightSaves.set(key, awaitSettled(next)); + return next; +} + // 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 @@ -217,45 +242,54 @@ 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({ - mutationFn: async (patch: ConfigPatch) => { - 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; - }, + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + 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); From e65f43aa1e6e2cd1359b87b4820522940c4f495f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:34:57 +0200 Subject: [PATCH 079/166] fix(mobile): kilo-chat route loading and cached-data precedence --- .../chat/[sandbox-id]/[conversation-id].tsx | 41 ++++++++++++------- .../(1_kiloclaw)/chat/[sandbox-id]/index.tsx | 9 ++-- .../kilo-chat/conversation-header.tsx | 2 +- .../conversation-history-state-views.tsx | 41 +++++++++++++++---- .../conversation-route-state.test.ts | 29 ++++++++++++- .../kilo-chat/conversation-route-state.ts | 20 +++++---- .../kilo-chat/conversation-screen.tsx | 12 +++++- .../kilo-chat/message-history-state.test.ts | 6 +++ .../kilo-chat/message-history-state.ts | 12 +++--- 9 files changed, 131 insertions(+), 41 deletions(-) diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/[conversation-id].tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/[conversation-id].tsx index cd723f1815..4b0bd19e87 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/[conversation-id].tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/[conversation-id].tsx @@ -6,10 +6,13 @@ import { captureEvent, SESSION_VIEWED_EVENT } from '@/lib/analytics/posthog'; import { ChatSandboxRouteMounts } from '@/components/kilo-chat/chat-sandbox-route-mounts'; import { ConversationScreen } from '@/components/kilo-chat/conversation-screen'; +import { + ConversationHistoryErrorView, + ConversationHistoryLoadingView, +} from '@/components/kilo-chat/conversation-history-state-views'; import { getConversationRouteDecision, getConversationRouteErrorMessage, - shouldRenderConversationScreen, } from '@/components/kilo-chat/conversation-route-state'; import { useConversationDetail } from '@/components/kilo-chat/hooks/use-conversations'; import { useKiloChatClient } from '@/components/kilo-chat/hooks/use-kilo-chat-client'; @@ -29,7 +32,11 @@ export default function ChatConversationRoute() { const conversationDetail = useConversationDetail(client, conversationId); const redirectPath = chatSandboxPath(sandboxId); const routeDecision = getConversationRouteDecision({ - detail: conversationDetail, + detail: { + data: conversationDetail.data, + error: conversationDetail.error, + isError: conversationDetail.isError, + }, routeSandboxId: sandboxId, }); @@ -43,24 +50,28 @@ export default function ChatConversationRoute() { }, [conversationDetail.data, conversationId, openedVia]); useEffect(() => { - 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/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..ab003a4a8c 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) { @@ -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-route-state.test.ts b/apps/mobile/src/components/kilo-chat/conversation-route-state.test.ts index 583911090a..4be60805ee 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 @@ -25,7 +25,7 @@ describe('shouldRenderConversationScreen', () => { it('does not render while the conversation detail is loading', () => { expect( shouldRenderConversationScreen({ - detail: { data: undefined, isError: false }, + detail: { data: undefined, error: undefined, isError: false }, routeSandboxId: 'sandbox-1', }) ).toBe(false); @@ -42,6 +42,7 @@ describe('shouldRenderConversationScreen', () => { { id: 'bot:kiloclaw:sandbox-1', kind: 'bot' }, ], }, + error: undefined, isError: false, }, routeSandboxId: 'sandbox-1', @@ -62,10 +63,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..300c1bfbd3 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,21 @@ import { type ConversationRouteDetailState = { data: { title: string | null; members: ConversationMember[] } | null | undefined; + error: unknown; isError: boolean; }; -type ConversationRouteDecision = 'pending' | 'ready' | 'error' | 'not-found'; +export 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 getConversationRouteErrorMessage(error: unknown): string { + return isConversationNotFoundError(error) + ? 'Conversation not found' + : 'Failed to load conversation'; } export function getConversationRouteDecision({ @@ -27,7 +31,9 @@ export function getConversationRouteDecision({ routeSandboxId: string; }): ConversationRouteDecision { if (detail.isError) { - return 'error'; + // Only a confirmed not-found/forbidden response should redirect away — + // transport/server errors are retryable in place (see T2.9). + return isConversationNotFoundError(detail.error) ? 'not-found' : 'retryable-error'; } if (detail.data === null || detail.data === undefined) { return 'pending'; diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index 41b93811e5..2d4c63ad15 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -13,6 +13,7 @@ import { ConversationHeader } from './conversation-header'; import { ConversationHistoryErrorView, ConversationHistoryLoadingView, + ConversationInlineRetryBanner, } from './conversation-history-state-views'; import { MessageInput } from './message-input'; import { MessageList } from './message-list'; @@ -78,7 +79,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) { @@ -217,6 +219,14 @@ export function ConversationScreen({ onSwitchInstance={handleSwitchInstance} onOpenOptions={handleOpenConversationOptions} /> + {messageHistoryState === 'stale-error' ? ( + { + void messagesQuery.refetch(); + }} + /> + ) : null} { '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..331332f45e 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'; +export 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({ From 5baa1e40bbbef92c5501d44764fcbf83b6677e52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:35:34 +0200 Subject: [PATCH 080/166] fix(mobile): org boundary discriminates errors from stale orgs --- .../organization/credit-activity-screen.tsx | 17 +++++- .../components/organization/hub-screen.tsx | 18 ++++++- .../organization/invite-member-sheet.tsx | 17 +++++- .../organization/invoices-screen.tsx | 17 +++++- .../organization/low-balance-alert-sheet.tsx | 54 ++++++++----------- .../low-balance-alert-validators.test.ts | 53 ++++++++++++++++++ .../low-balance-alert-validators.ts | 31 +++++++++++ .../organization/member-limit-sheet.tsx | 39 ++++++-------- .../member-limit-validators.test.ts | 37 +++++++++++++ .../organization/member-limit-validators.ts | 23 ++++++++ .../organization/members-screen.tsx | 18 ++++++- .../organization/organization-boundary.tsx | 53 +++++++++++++++--- .../src/lib/hooks/use-organization-queries.ts | 4 +- 13 files changed, 305 insertions(+), 76 deletions(-) create mode 100644 apps/mobile/src/components/organization/low-balance-alert-validators.test.ts create mode 100644 apps/mobile/src/components/organization/low-balance-alert-validators.ts create mode 100644 apps/mobile/src/components/organization/member-limit-validators.test.ts create mode 100644 apps/mobile/src/components/organization/member-limit-validators.ts diff --git a/apps/mobile/src/components/organization/credit-activity-screen.tsx b/apps/mobile/src/components/organization/credit-activity-screen.tsx index cabdad3a54..21a22122a2 100644 --- a/apps/mobile/src/components/organization/credit-activity-screen.tsx +++ b/apps/mobile/src/components/organization/credit-activity-screen.tsx @@ -67,7 +67,14 @@ function CreditRow({ transaction }: Readonly<{ transaction: CreditTransaction }> } export function OrganizationCreditActivityScreen() { - const { organizationId, org, isResolving } = useOrgBoundary(); + const { + organizationId, + org, + isResolving, + isError: isOrgListError, + isFetching: isOrgListFetching, + refetch: refetchOrgList, + } = useOrgBoundary(); const query = useOrgCreditTransactions(organizationId); const paddingBottom = useTabBarBottomPadding(); @@ -75,7 +82,13 @@ export function OrganizationCreditActivityScreen() { return ( - + ); } diff --git a/apps/mobile/src/components/organization/hub-screen.tsx b/apps/mobile/src/components/organization/hub-screen.tsx index 0dc1d2cc7d..fc3d89b391 100644 --- a/apps/mobile/src/components/organization/hub-screen.tsx +++ b/apps/mobile/src/components/organization/hub-screen.tsx @@ -28,7 +28,15 @@ import { useThemeColors } from '@/lib/hooks/use-theme-colors'; export function OrganizationHubScreen() { const router = useRouter(); const colors = useThemeColors(); - const { organizationId, role, org, isResolving } = useOrgBoundary(); + const { + organizationId, + role, + org, + isResolving, + isError: isOrgListError, + isFetching: isOrgListFetching, + refetch: refetchOrgList, + } = useOrgBoundary(); const orgWithMembers = useOrgWithMembers(organizationId); const mutations = useOrganizationMutations(organizationId ?? ''); const [renameVisible, setRenameVisible] = useState(false); @@ -37,7 +45,13 @@ export function OrganizationHubScreen() { return ( - + ); } diff --git a/apps/mobile/src/components/organization/invite-member-sheet.tsx b/apps/mobile/src/components/organization/invite-member-sheet.tsx index d5ae646056..bc61d24649 100644 --- a/apps/mobile/src/components/organization/invite-member-sheet.tsx +++ b/apps/mobile/src/components/organization/invite-member-sheet.tsx @@ -23,7 +23,15 @@ const EMAIL_ERROR = 'Enter a valid email address'; export function InviteMemberSheet() { const router = useRouter(); const colors = useThemeColors(); - const { organizationId, role: myRole, org, isResolving } = useOrgBoundary(); + const { + organizationId, + role: myRole, + org, + isResolving, + isError: isOrgListError, + isFetching: isOrgListFetching, + refetch: refetchOrgList, + } = useOrgBoundary(); const mutations = useOrganizationMutations(organizationId ?? ''); const emailRef = useRef(''); const [canSubmit, setCanSubmit] = useState(false); @@ -42,7 +50,12 @@ export function InviteMemberSheet() { if (organizationId == null || org == null) { return ( - + ); } diff --git a/apps/mobile/src/components/organization/invoices-screen.tsx b/apps/mobile/src/components/organization/invoices-screen.tsx index 0de7516048..c8d0a79c6c 100644 --- a/apps/mobile/src/components/organization/invoices-screen.tsx +++ b/apps/mobile/src/components/organization/invoices-screen.tsx @@ -70,7 +70,14 @@ function InvoiceRow({ invoice }: Readonly<{ invoice: OrgInvoice }>) { } export function OrganizationInvoicesScreen() { - const { organizationId, org, isResolving } = useOrgBoundary(); + const { + organizationId, + org, + isResolving, + isError: isOrgListError, + isFetching: isOrgListFetching, + refetch: refetchOrgList, + } = useOrgBoundary(); const query = useOrgInvoices(organizationId); const paddingBottom = useTabBarBottomPadding(); @@ -78,7 +85,13 @@ export function OrganizationInvoicesScreen() { return ( - + ); } 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 73173f2e3a..8ff7e8fe6c 100644 --- a/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx +++ b/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx @@ -4,6 +4,12 @@ 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'; @@ -18,37 +24,6 @@ import { useOrgBoundary, useOrgWithMembers, } from '@/lib/hooks/use-organization-queries'; -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'; - -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 !== ''); -} - -function thresholdError(value: string): string | null { - return parseThreshold(value) == null ? THRESHOLD_ERROR : null; -} - -function emailsError(value: string): string | null { - const emails = parseEmails(value); - return emails.length === 0 || !emails.every(email => EMAIL_PATTERN.test(email)) - ? EMAILS_ERROR - : null; -} type LowBalanceAlertFormProps = Readonly<{ organizationId: string | null; @@ -188,7 +163,15 @@ function LowBalanceAlertForm({ organizationId, settings }: LowBalanceAlertFormPr } export function LowBalanceAlertSheet() { - const { organizationId, role, org, isResolving } = useOrgBoundary(); + const { + organizationId, + role, + org, + isResolving, + isError: isOrgListError, + isFetching: isOrgListFetching, + refetch: refetchOrgList, + } = useOrgBoundary(); const orgWithMembers = useOrgWithMembers(organizationId); if (isResolving) { @@ -202,7 +185,12 @@ export function LowBalanceAlertSheet() { if (organizationId == null || org == null) { return ( - + ); } 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..75816366e9 --- /dev/null +++ b/apps/mobile/src/components/organization/low-balance-alert-validators.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { + emailsError, + parseEmails, + parseThreshold, + thresholdError, +} from '@/components/organization/low-balance-alert-validators'; + +describe('parseThreshold', () => { + it('parses a positive number', () => { + expect(parseThreshold('10')).toBe(10); + }); + + it('rejects blank, zero, negative, and non-numeric input', () => { + expect(parseThreshold('')).toBeNull(); + expect(parseThreshold('0')).toBeNull(); + expect(parseThreshold('-5')).toBeNull(); + expect(parseThreshold('abc')).toBeNull(); + }); +}); + +describe('thresholdError', () => { + it('returns null for a valid amount', () => { + expect(thresholdError('10')).toBeNull(); + }); + + it('returns an error message for an invalid amount', () => { + expect(thresholdError('')).not.toBeNull(); + expect(thresholdError('0')).not.toBeNull(); + }); +}); + +describe('parseEmails', () => { + it('splits, trims, and drops empty entries', () => { + expect(parseEmails('a@x.com, b@x.com ,, ')).toEqual(['a@x.com', 'b@x.com']); + }); +}); + +describe('emailsError', () => { + it('returns null when every email is valid', () => { + expect(emailsError('a@x.com, b@x.com')).toBeNull(); + }); + + it('returns an error when the list is empty', () => { + expect(emailsError('')).not.toBeNull(); + expect(emailsError(' , ')).not.toBeNull(); + }); + + it('returns an error 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 a719dda3ec..4d6b6bec64 100644 --- a/apps/mobile/src/components/organization/member-limit-sheet.tsx +++ b/apps/mobile/src/components/organization/member-limit-sheet.tsx @@ -4,6 +4,7 @@ import { useRef, useState } from 'react'; 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'; @@ -18,27 +19,6 @@ import { } from '@/lib/hooks/use-organization-queries'; import { firstNonEmpty } from '@/lib/utils'; -const MAX_DAILY_LIMIT_USD = 2000; -const LIMIT_RANGE_ERROR = `Enter an amount between 0 and ${MAX_DAILY_LIMIT_USD}`; - -// A blank field is valid — it means "no limit" (same as pressing Remove limit). -function limitError(value: string): string | 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 LIMIT_RANGE_ERROR; - } - return null; -} - -function parseLimit(value: string): number | null { - const trimmed = value.trim(); - return trimmed === '' ? null : Number(trimmed); -} - type MemberLimitFormProps = Readonly<{ memberId: string; organizationId: string | null; @@ -116,7 +96,15 @@ function MemberLimitForm({ memberId, organizationId, member }: MemberLimitFormPr } export function MemberLimitSheet({ memberId }: Readonly<{ memberId: string }>) { - const { organizationId, role, org, isResolving } = useOrgBoundary(); + const { + organizationId, + role, + org, + isResolving, + isError: isOrgListError, + isFetching: isOrgListFetching, + refetch: refetchOrgList, + } = useOrgBoundary(); const orgWithMembers = useOrgWithMembers(organizationId); const member = orgWithMembers.data?.members.find( (m): m is ActiveOrgMember => m.status === 'active' && m.id === memberId @@ -138,7 +126,12 @@ export function MemberLimitSheet({ memberId }: Readonly<{ memberId: string }>) { if (organizationId == null || org == null) { 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..0bff163553 --- /dev/null +++ b/apps/mobile/src/components/organization/member-limit-validators.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { + limitError, + MAX_DAILY_LIMIT_USD, + parseLimit, +} 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(); + }); + + it('accepts an amount within range', () => { + expect(limitError('10')).toBeNull(); + expect(limitError(String(MAX_DAILY_LIMIT_USD))).toBeNull(); + expect(limitError('0')).toBeNull(); + }); + + it('rejects out-of-range or non-numeric input', () => { + expect(limitError('-1')).not.toBeNull(); + expect(limitError(String(MAX_DAILY_LIMIT_USD + 1))).not.toBeNull(); + expect(limitError('abc')).not.toBeNull(); + }); +}); + +describe('parseLimit', () => { + it('parses a numeric string', () => { + expect(parseLimit('42')).toBe(42); + }); + + it('parses blank as null', () => { + expect(parseLimit('')).toBeNull(); + expect(parseLimit(' ')).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..c383f0edbe --- /dev/null +++ b/apps/mobile/src/components/organization/member-limit-validators.ts @@ -0,0 +1,23 @@ +export 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/members-screen.tsx b/apps/mobile/src/components/organization/members-screen.tsx index 26cce117ba..1dd8de9bf0 100644 --- a/apps/mobile/src/components/organization/members-screen.tsx +++ b/apps/mobile/src/components/organization/members-screen.tsx @@ -59,7 +59,15 @@ function MemberRowSkeleton({ last }: Readonly<{ last?: boolean }>) { export function OrganizationMembersScreen() { const router = useRouter(); const colors = useThemeColors(); - const { organizationId, role, org, isResolving } = useOrgBoundary(); + const { + organizationId, + role, + org, + isResolving, + isError: isOrgListError, + isFetching: isOrgListFetching, + refetch: refetchOrgList, + } = useOrgBoundary(); const orgWithMembers = useOrgWithMembers(organizationId); const { userId: currentUserId } = useCurrentUserId(); @@ -67,7 +75,13 @@ export function OrganizationMembersScreen() { return ( - + ); } diff --git a/apps/mobile/src/components/organization/organization-boundary.tsx b/apps/mobile/src/components/organization/organization-boundary.tsx index 2992db340e..738fb4f738 100644 --- a/apps/mobile/src/components/organization/organization-boundary.tsx +++ b/apps/mobile/src/components/organization/organization-boundary.tsx @@ -3,6 +3,7 @@ import { Building2 } from 'lucide-react-native'; import { ActivityIndicator, View } from 'react-native'; import { EmptyState } from '@/components/empty-state'; +import { QueryError } from '@/components/query-error'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; @@ -12,16 +13,35 @@ const PROFILE_HREF = '/(app)/(tabs)/(3_profile)' as Href; type OrganizationBoundaryProps = Readonly<{ /** Identity/org-list is still resolving — show a brief in-progress state instead of the empty state. */ isResolving?: boolean; + /** The underlying `organizations.list` query failed — a retryable fetch failure, not a stale org selection. */ + isError?: boolean; + isFetching?: boolean; + refetch?: () => unknown; + /** Null when no organization has ever been selected — gets distinct copy from "selection no longer resolves". */ + organizationId?: string | null; }>; /** - * Content shown in place of an organization screen or sheet when the - * persisted org selection doesn't resolve to a real membership (stale/ - * deleted org, or no org selected at all) — never renders `null`, so the - * route is never blank. Callers own their own chrome (`ScreenHeader` for - * full screens, nothing for form sheets) — this only renders the content. + * 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. + * Callers own their own chrome (`ScreenHeader` for full screens, nothing for + * form sheets) — this only renders the content. */ -export function OrganizationBoundary({ isResolving }: OrganizationBoundaryProps = {}) { +export function OrganizationBoundary({ + isResolving, + isError, + isFetching, + refetch, + organizationId, +}: OrganizationBoundaryProps = {}) { const router = useRouter(); const colors = useThemeColors(); @@ -33,11 +53,28 @@ export function OrganizationBoundary({ isResolving }: OrganizationBoundaryProps ); } + if (isError) { + return ( + void refetch() : undefined} + isRetrying={isFetching} + /> + ); + } + + const noSelection = organizationId == null; + return ( Date: Sat, 11 Jul 2026 11:35:37 +0200 Subject: [PATCH 081/166] fix(mobile): surface kilo-chat token failures with retry --- .../kilo-chat/conversation-screen.tsx | 10 ++++ .../kilo-chat/kilo-chat-provider.tsx | 52 +++++++++++++++---- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index 2d4c63ad15..e9b5a52be8 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -29,6 +29,7 @@ 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 { useKiloChatTokenError } from './kilo-chat-provider'; import { useAllKiloClawInstances, useInstanceContext } from '@/lib/hooks/use-instance-context'; import { useKiloClawStatus } from '@/lib/hooks/use-kiloclaw-queries'; import { kiloclawConversationEyebrow } from '@/lib/kiloclaw-display'; @@ -58,6 +59,7 @@ export function ConversationScreen({ const eventClient = useEventServiceClient(); const router = useRouter(); const currentUserId = useCurrentUserId(); + const tokenError = useKiloChatTokenError(); const { showActionSheetWithOptions } = useActionSheet(); const { bottom } = useSafeAreaInsets(); const instanceContext = useInstanceContext(sandboxId); @@ -219,6 +221,14 @@ export function ConversationScreen({ onSwitchInstance={handleSwitchInstance} onOpenOptions={handleOpenConversationOptions} /> + {tokenError.hasError ? ( + { + tokenError.retry(); + }} + /> + ) : null} {messageHistoryState === 'stale-error' ? ( (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} + + ); } From a1133eca53b7307eb4d45fd5ad104d21fcfe7900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:35:57 +0200 Subject: [PATCH 082/166] fix(mobile): security settings read-only and validation truth --- .../analysis-settings-screen.tsx | 84 ++++++++++++------- .../notification-settings-screen.tsx | 28 +++++-- .../repository-settings-screen.tsx | 29 ++++--- .../security-agent/settings-pill-group.tsx | 5 +- .../security-agent/sla-settings-screen.tsx | 49 +++++++---- 5 files changed, 128 insertions(+), 67 deletions(-) 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 62ce3cf97d..973a9952f2 100644 --- a/apps/mobile/src/components/security-agent/analysis-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/analysis-settings-screen.tsx @@ -56,9 +56,12 @@ export function AnalysisSettingsScreen({ scope }: Readonly<{ scope: string }>) { const canManage = useSecurityAgentEditCapability(scope); 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(''); @@ -149,6 +152,11 @@ export function AnalysisSettingsScreen({ scope }: Readonly<{ scope: string }>) { } /> + {!canManage && ( + + Only organization owners and billing managers can change these settings. + + )} Analysis depth @@ -162,13 +170,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/notification-settings-screen.tsx b/apps/mobile/src/components/security-agent/notification-settings-screen.tsx index 9a59f87a4f..bce810e06a 100644 --- a/apps/mobile/src/components/security-agent/notification-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/notification-settings-screen.tsx @@ -28,6 +28,7 @@ import { } from '@/lib/hooks/use-security-agent'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { type SecurityAgentConfig } from '@/lib/security-agent'; +import { cn } from '@/lib/utils'; type NotificationSeverity = SecurityAgentConfig['newFindingNotificationMinSeverity']; @@ -113,13 +114,20 @@ export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string } trackRef.current({ interaction: 'settings_notifications_viewed' }); }, []); - const valid = isValidDayCount(slaNotificationWarningDays); + // Only validate the lead-time field while the SLA notification feature + // that owns it is enabled — once hidden by the toggle, its value can't + // block saving. If the field is invalid at the moment it's hidden, fall + // back to the last persisted value instead of sending an invalid one. + const warningDaysValid = isValidDayCount(slaNotificationWarningDays); + const valid = !slaNotificationsEnabled || warningDaysValid; const patch = { newFindingNotificationsEnabled, newFindingNotificationMinSeverity, slaNotificationsEnabled, slaNotificationMinSeverity, - slaNotificationWarningDays, + slaNotificationWarningDays: warningDaysValid + ? slaNotificationWarningDays + : (initialConfigRef.current.slaNotificationWarningDays ?? slaNotificationWarningDays), }; const dirty = hydratedRef.current && @@ -173,6 +181,11 @@ export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string } contentContainerClassName="gap-6 pt-4" automaticallyAdjustKeyboardInsets > + {!canManage && ( + + Only organization owners and billing managers can change these settings. + + )} New-finding Notification @@ -226,7 +239,10 @@ export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string } ? undefined : 'Enter a whole number between 1 and 365' } - className="h-11 rounded-lg bg-secondary px-3 text-sm leading-5 text-foreground" + className={cn( + 'h-11 rounded-lg bg-secondary px-3 text-sm leading-5 text-foreground', + !canManage && 'opacity-50' + )} editable={canManage} keyboardType="number-pad" defaultValue={warningDaysRef.current} @@ -246,12 +262,6 @@ export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string } )} - - {!canManage && ( - - Only organization owners and billing managers can change these settings. - - )} ); diff --git a/apps/mobile/src/components/security-agent/repository-settings-screen.tsx b/apps/mobile/src/components/security-agent/repository-settings-screen.tsx index 717d73de9b..e81f94d91f 100644 --- a/apps/mobile/src/components/security-agent/repository-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/repository-settings-screen.tsx @@ -31,6 +31,7 @@ import { } from '@/lib/hooks/use-security-agent'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { type SecurityAgentConfig } from '@/lib/security-agent'; +import { cn } from '@/lib/utils'; type RepositorySelectionMode = SecurityAgentConfig['repositorySelectionMode']; @@ -137,16 +138,24 @@ export function RepositorySettingsScreen({ scope }: Readonly<{ scope: string }>) } /> + {!canManage && ( + + Only organization owners and billing managers can change these settings. + + )} {(['all', 'selected'] as const).map(option => ( { setModeOption(option); }} accessibilityRole="radio" - accessibilityState={{ selected: mode === option }} + accessibilityState={{ selected: mode === option, disabled: !canManage }} > {option === 'all' ? 'All repositories' : 'Selected repositories'} @@ -205,12 +214,18 @@ export function RepositorySettingsScreen({ scope }: Readonly<{ scope: string }>) { toggleRepo(repo.id); }} accessibilityRole="checkbox" - accessibilityState={{ checked: selectedIds.includes(repo.id) }} + accessibilityState={{ + checked: selectedIds.includes(repo.id), + disabled: !canManage, + }} > {repo.private ? : null} @@ -234,12 +249,6 @@ export function RepositorySettingsScreen({ scope }: Readonly<{ scope: string }>) )} )} - - {!canManage && ( - - Only organization owners and billing managers can change these settings. - - )} ); 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..0e63c976a7 100644 --- a/apps/mobile/src/components/security-agent/settings-pill-group.tsx +++ b/apps/mobile/src/components/security-agent/settings-pill-group.tsx @@ -41,14 +41,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 }} > + {label} @@ -157,17 +163,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 = { + critical: isValidDayCount(slaCriticalDays), + high: isValidDayCount(slaHighDays), + medium: isValidDayCount(slaMediumDays), + low: isValidDayCount(slaLowDays), + }; + const valid = !slaEnabled || Object.values(daysValid).every(Boolean); const patch = { slaEnabled, - slaCriticalDays, - slaHighDays, - slaMediumDays, - slaLowDays, + slaCriticalDays: daysValid.critical + ? slaCriticalDays + : (initialConfigRef.current.slaCriticalDays ?? slaCriticalDays), + slaHighDays: daysValid.high + ? slaHighDays + : (initialConfigRef.current.slaHighDays ?? slaHighDays), + slaMediumDays: daysValid.medium + ? slaMediumDays + : (initialConfigRef.current.slaMediumDays ?? slaMediumDays), + slaLowDays: daysValid.low ? slaLowDays : (initialConfigRef.current.slaLowDays ?? slaLowDays), }; const dirty = hydratedRef.current && @@ -234,6 +252,11 @@ export function SlaSettingsScreen({ scope }: Readonly<{ scope: string }>) { contentContainerClassName="gap-6 pt-4" automaticallyAdjustKeyboardInsets > + {!canManage && ( + + Only organization owners and billing managers can change these settings. + + )} ) { ))} )} - - {!canManage && ( - - Only organization owners and billing managers can change these settings. - - )} ); From 2780bfeaa506cf3419a9856e8f4819db6a1a75b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:36:35 +0200 Subject: [PATCH 083/166] fix(mobile): review list and detail states --- .../code-reviewer/platform-list-screen.tsx | 5 +- .../code-reviewer/review-detail-screen.tsx | 54 +++++++++++++------ .../code-reviewer/review-list-screen.tsx | 21 ++++++++ 3 files changed, 64 insertions(+), 16 deletions(-) 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 872748b247..154485c022 100644 --- a/apps/mobile/src/components/code-reviewer/platform-list-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/platform-list-screen.tsx @@ -25,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'; } 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 667fc25683..97e2b1273f 100644 --- a/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx @@ -51,35 +51,50 @@ export function ReviewDetailScreen({ scope, reviewId, }: Readonly<{ scope: string; reviewId: string }>) { - const { data, isLoading, isError, isFetching, 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()} - isRetrying={isFetching} - /> + + + + + ); } - if (isLoading || !data) { + // A thrown NOT_FOUND/FORBIDDEN can never be fixed by retrying — show a + // plain message with no "Retry" affordance. 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) { + const errorCode = isError ? error.data?.code : undefined; + if (errorCode === 'NOT_FOUND' || errorCode === 'FORBIDDEN') { + return ( + + + + + + + ); + } return ( - - - - - + + void refetch()} + isRetrying={isFetching} + /> ); @@ -110,6 +125,15 @@ export function ReviewDetailScreen({ + {/* 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} 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 2897dae3a8..ec707cfb15 100644 --- a/apps/mobile/src/components/code-reviewer/review-list-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/review-list-screen.tsx @@ -11,9 +11,11 @@ import { 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'; @@ -46,6 +48,10 @@ function reviewTime(review: Review): Date { export function ReviewListScreen({ scope }: Readonly<{ scope: string }>) { const router = useRouter(); 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 ( @@ -90,6 +96,21 @@ export function ReviewListScreen({ scope }: Readonly<{ scope: string }>) { title="No reviews yet" description="Reviews appear here once the Code Reviewer runs on a pull request." className="pt-12" + action={ + + } /> )} From a679f5e7c20317e43171acc63591d3dd40ee22ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:37:57 +0200 Subject: [PATCH 084/166] fix(mobile): app-store-safe zero-balance copy on iOS --- apps/mobile/src/components/profile-credits-card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/components/profile-credits-card.tsx b/apps/mobile/src/components/profile-credits-card.tsx index 36abbaf5fe..2d68791405 100644 --- a/apps/mobile/src/components/profile-credits-card.tsx +++ b/apps/mobile/src/components/profile-credits-card.tsx @@ -169,7 +169,7 @@ export function CreditsCard({ enabled, orgs }: Readonly) { {canShowZeroBalanceCta ? 'Add credits to keep usage running.' - : "Add credits from a browser to keep using Kilo — you can't purchase credits in the app."} + : 'Your credit balance is empty. Credits are managed outside the iOS app for this account.'} {canShowZeroBalanceCta && ( + } + /> + ) : null} + {showList + ? loadedInstances.map(instance => ( + + )) : null} ); From 7359299dd5c35010d3671854115a415c2302d21b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:40:10 +0200 Subject: [PATCH 087/166] fix(mobile): inline rename-conversation errors --- .../(1_kiloclaw)/rename-conversation.tsx | 21 ++++- .../kilo-chat/hooks/use-conversations.ts | 8 +- .../kilo-chat/rename-conversation-sheet.tsx | 82 ++++++++++--------- 3 files changed, 65 insertions(+), 46 deletions(-) diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/rename-conversation.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/rename-conversation.tsx index a884d365ab..cdb9754592 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/rename-conversation.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/rename-conversation.tsx @@ -1,4 +1,6 @@ +import { formatKiloChatError } from '@kilocode/kilo-chat'; import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useEffect, useState } from 'react'; import { RenameConversationSheet } from '@/components/kilo-chat/rename-conversation-sheet'; import { useKiloChatClient } from '@/components/kilo-chat/hooks/use-kilo-chat-client'; @@ -14,24 +16,37 @@ export default function RenameConversationRoute() { }>(); const renameConversation = useRenameConversation(client); const initialTitle = typeof title === 'string' ? title : ''; + const [errorText, setErrorText] = useState(null); + + useEffect(() => { + if (!conversationId) { + router.back(); + } + }, [conversationId, router]); + + if (!conversationId) { + return null; + } return ( { router.back(); }} onSave={nextTitle => { - if (!conversationId) { - return; - } + setErrorText(null); renameConversation.mutate( { conversationId, title: nextTitle, sandboxId }, { onSuccess: () => { router.back(); }, + onError: err => { + setErrorText(formatKiloChatError(err, 'Failed to rename conversation')); + }, } ); }} 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..48911d3517 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 — the only caller is the rename form sheet, + // which stays open on failure and shows the error inline (see P2). + return useSharedRenameConversation(client); } export function useLeaveConversation(client: KiloChatClient) { diff --git a/apps/mobile/src/components/kilo-chat/rename-conversation-sheet.tsx b/apps/mobile/src/components/kilo-chat/rename-conversation-sheet.tsx index 6f33437c26..f7e89133c5 100644 --- a/apps/mobile/src/components/kilo-chat/rename-conversation-sheet.tsx +++ b/apps/mobile/src/components/kilo-chat/rename-conversation-sheet.tsx @@ -1,6 +1,6 @@ import { CONVERSATION_TITLE_MAX_CHARS } from '@kilocode/kilo-chat'; import { useRef, useState } from 'react'; -import { Pressable, TextInput, View } from 'react-native'; +import { Pressable, ScrollView, TextInput, View } from 'react-native'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; @@ -9,6 +9,7 @@ import { useThemeColors } from '@/lib/hooks/use-theme-colors'; type RenameConversationSheetProps = { initialTitle: string; isSaving: boolean; + errorText: string | null; onCancel: () => void; onSave: (title: string) => void; }; @@ -25,6 +26,7 @@ function canSaveTitle(text: string, initialTitle: string): boolean { export function RenameConversationSheet({ initialTitle, isSaving, + errorText, onCancel, onSave, }: Readonly) { @@ -45,43 +47,47 @@ export function RenameConversationSheet({ } return ( - - - - Rename conversation - Set a short name for this thread. - - - - - Cancel - - - + + + Rename conversation + Set a short name for this thread. - + + {errorText ? {errorText} : null} + + + Cancel + + + + ); } From 081411cfadea66a5fea3e9145cf9aee56f7b3259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:40:13 +0200 Subject: [PATCH 088/166] fix(mobile): dismiss-finding sheet validation and inline errors --- .../security-agent/dismiss-finding-screen.tsx | 221 +++++++++++++----- .../src/lib/hooks/use-security-findings.ts | 6 +- 2 files changed, 167 insertions(+), 60 deletions(-) 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..520637e654 100644 --- a/apps/mobile/src/components/security-agent/dismiss-finding-screen.tsx +++ b/apps/mobile/src/components/security-agent/dismiss-finding-screen.tsx @@ -1,11 +1,16 @@ import { useRouter } from 'expo-router'; -import { Check } from 'lucide-react-native'; +import { Check, ShieldOff } from 'lucide-react-native'; import { useRef, useState } from 'react'; import { ActivityIndicator, Pressable, 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 { 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'; @@ -27,9 +32,24 @@ type DismissFindingScreenProps = { 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,152 @@ 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 ( + + + + + + Reason + + + {DISMISS_REASONS.map(([value, label], index) => { + const selected = reason === value; + return ( + { + setReason(value); + }} + accessibilityRole="radio" + accessibilityState={{ selected }} + > + {label} + {selected && } + + ); + })} + + + + + + Comment (optional) + + { + commentRef.current = value; + }} + /> + + + {dismissFinding.isError && ( + {dismissFinding.error.message} + )} - - + + + ); } diff --git a/apps/mobile/src/lib/hooks/use-security-findings.ts b/apps/mobile/src/lib/hooks/use-security-findings.ts index d966b6d34b..315e897d18 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); }, From eb888158d4bbe6900e378bddb79e5f7a9709e44b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:41:17 +0200 Subject: [PATCH 089/166] refactor(mobile): shared access-issue URL resolution --- .../components/kiloclaw/access-required-screen.tsx | 11 ++--------- .../components/kiloclaw/instance-list-screen.tsx | 12 +----------- apps/mobile/src/lib/kiloclaw/access-issue.ts | 13 +++++++++++++ 3 files changed, 16 insertions(+), 20 deletions(-) create mode 100644 apps/mobile/src/lib/kiloclaw/access-issue.ts diff --git a/apps/mobile/src/components/kiloclaw/access-required-screen.tsx b/apps/mobile/src/components/kiloclaw/access-required-screen.tsx index e9a3a64832..733ce5d704 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') { diff --git a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx index 8de3decd7b..2ebeff6d91 100644 --- a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx @@ -12,10 +12,10 @@ 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 { WEB_BASE_URL } from '@/lib/config'; 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[]; @@ -42,16 +42,6 @@ const ACCESS_ISSUE_LABELS: Record = { non_canonical_earlybird: 'Legacy plan needs review', }; -const SUBSCRIBE_SUBCASES: ReadonlySet = new Set([ - 'trial_expired', - 'subscription_canceled', - 'subscription_past_due', -]); - -function resolveAccessIssueUrl(subcase: AccessRequiredSubcase): string { - return SUBSCRIBE_SUBCASES.has(subcase) ? `${WEB_BASE_URL}/claw` : WEB_BASE_URL; -} - function splitInstances(instances: ClawInstance[]) { return { personal: instances.filter(instance => instance.organizationId === null), 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; +} From 188e87e8662f63b98e7438022857ebc0f6578d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:42:29 +0200 Subject: [PATCH 090/166] fix(mobile): bot-offline banner links to instance --- .../kilo-chat/bot-send-state.test.ts | 55 ++++++++++++------- .../components/kilo-chat/bot-send-state.ts | 10 ++++ .../kilo-chat/conversation-screen.tsx | 8 ++- .../kilo-chat/message-input-content.tsx | 4 ++ .../kilo-chat/message-input-types.ts | 2 + .../kilo-chat/message-input-view.tsx | 18 +++++- 6 files changed, 74 insertions(+), 23 deletions(-) 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..93feac3a6c 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 @@ -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(true); }); 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..6ae3bc9341 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,12 @@ export function resolveMobileMessageInputAvailability(params: { botDisplay.state === 'unknown' ? 'Waiting for bot status...' : 'Bot is offline. Messages will resume when it reconnects.', + // ponytail: no time-since-first-unknown tracking here (this fn stays a pure, + // easily-testable state map) — show the CTA as soon as the composer is + // blocked on offline/unknown, since bot status normally arrives within + // seconds of connecting. Add a real "prolonged" threshold if that proves + // too eager in practice. + showInstanceCta: true, submitDisabled: true, }; } diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index e9b5a52be8..1e473ceffe 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -4,7 +4,7 @@ import { useBotStatus, useEventServiceClient } from '@kilocode/kilo-chat-hooks'; import { type ConversationDetailResponse } from '@kilocode/kilo-chat'; import { useCallback } from 'react'; import { Alert, View } from 'react-native'; -import { useFocusEffect, useRouter } from 'expo-router'; +import { type Href, useFocusEffect, useRouter } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { toast } from 'sonner-native'; @@ -114,6 +114,10 @@ export function ConversationScreen({ router.push(chatInstancePickerPath(sandboxId)); }, [router, sandboxId]); + const handleOpenInstance = useCallback(() => { + router.push(`/(app)/kiloclaw/${sandboxId}/dashboard` as Href); + }, [router, sandboxId]); + const handleOpenConversationOptions = useCallback(() => { void Haptics.selectionAsync(); showActionSheetWithOptions( @@ -264,6 +268,8 @@ export function ConversationScreen({ disabled={messageController.inputAvailability.disabled} submitDisabled={messageController.inputAvailability.submitDisabled} disabledReason={messageController.inputAvailability.disabledReason} + showInstanceCta={messageController.inputAvailability.showInstanceCta} + onOpenInstance={handleOpenInstance} initialText={messageController.editingText} isEditing={messageController.editingMessage !== null} editableAttachments={messageController.visibleEditingAttachments} 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-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..a864e20651 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,18 @@ export function MessageInputView({ )} {disabledReason && ( - - {disabledReason} + + {disabledReason} + {showInstanceCta && onOpenInstance ? ( + + Open instance + + ) : null} )} {attachmentQueue && ( From df3087d4a12e994b11bbed3d36ba646c65325ffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:43:25 +0200 Subject: [PATCH 091/166] fix(mobile): drop unused exported kilo-chat state types --- .../mobile/src/components/kilo-chat/conversation-route-state.ts | 2 +- apps/mobile/src/components/kilo-chat/message-history-state.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 300c1bfbd3..c2cddde7d0 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-route-state.ts +++ b/apps/mobile/src/components/kilo-chat/conversation-route-state.ts @@ -10,7 +10,7 @@ type ConversationRouteDetailState = { isError: boolean; }; -export type ConversationRouteDecision = 'pending' | 'ready' | 'retryable-error' | 'not-found'; +type ConversationRouteDecision = 'pending' | 'ready' | 'retryable-error' | 'not-found'; function isConversationNotFoundError(error: unknown): boolean { const status = error instanceof KiloChatApiError ? error.status : undefined; 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 331332f45e..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 @@ -export type MessageHistoryContentState = 'loading' | 'error' | 'ready' | 'stale-error'; +type MessageHistoryContentState = 'loading' | 'error' | 'ready' | 'stale-error'; export function getMessageHistoryContentState({ isPending, From 46eca1f31cc85c72be8aa2a6268dff88ab408e52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:45:58 +0200 Subject: [PATCH 092/166] chore(mobile): drop comment prefixes --- apps/mobile/src/components/kilo-chat/bot-send-state.ts | 2 +- apps/mobile/src/lib/auth/use-native-auth.ts | 4 ++-- apps/mobile/src/lib/hooks/use-session-mutations.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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 6ae3bc9341..ccffc81146 100644 --- a/apps/mobile/src/components/kilo-chat/bot-send-state.ts +++ b/apps/mobile/src/components/kilo-chat/bot-send-state.ts @@ -93,7 +93,7 @@ export function resolveMobileMessageInputAvailability(params: { botDisplay.state === 'unknown' ? 'Waiting for bot status...' : 'Bot is offline. Messages will resume when it reconnects.', - // ponytail: no time-since-first-unknown tracking here (this fn stays a pure, + // No time-since-first-unknown tracking here (this fn stays a pure, // easily-testable state map) — show the CTA as soon as the composer is // blocked on offline/unknown, since bot status normally arrives within // seconds of connecting. Add a real "prolonged" threshold if that proves 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/hooks/use-session-mutations.ts b/apps/mobile/src/lib/hooks/use-session-mutations.ts index 7415009d2a..03c42b55ad 100644 --- a/apps/mobile/src/lib/hooks/use-session-mutations.ts +++ b/apps/mobile/src/lib/hooks/use-session-mutations.ts @@ -19,7 +19,7 @@ const onError = (error: { message: string }) => { export function useSessionMutations() { const trpc = useTRPC(); const queryClient = useQueryClient(); - // ponytail: a ref (not state) is enough — this only gates duplicate taps + // A ref (not state) is enough — this only gates duplicate taps // on the same row, it never needs to drive a re-render. const pendingSessionIds = useRef(new Set()); const listKey = trpc.cliSessionsV2.list.infiniteQueryKey(); From a0696fb8b61c388ebb82f2d490fd01d8ad8414b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:46:04 +0200 Subject: [PATCH 093/166] fix(mobile): security-agent targets, empty centering, SLA zero state --- .../security-agent/[scope]/filter.tsx | 14 ++++--- .../security-agent/collapsible-section.tsx | 1 + .../security-agent/dashboard-screen.tsx | 37 +++++++++++++++++-- .../security-agent/finding-detail-screen.tsx | 5 +-- .../security-agent/finding-list-screen.tsx | 9 ++--- .../components/security-agent/finding-row.tsx | 4 +- .../src/security-agent/dashboard.test.ts | 6 +-- .../src/security-agent/dashboard.ts | 21 ++++++----- 8 files changed, 66 insertions(+), 31 deletions(-) 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 index ea896e9e3b..39124efc6b 100644 --- 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 @@ -1,10 +1,11 @@ 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 { Text } from '@/components/ui/text'; import { clearSecurityFindingFilterBridge, getSecurityFindingFilterBridge, @@ -36,10 +37,13 @@ export default function SecurityAgentFilterFindingsRoute() { if (!bridge) { return ( - - - No filters available - + + ); } 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 aeb501cdae..665ccd3350 100644 --- a/apps/mobile/src/components/security-agent/dashboard-screen.tsx +++ b/apps/mobile/src/components/security-agent/dashboard-screen.tsx @@ -201,9 +201,7 @@ export function DashboardScreen({ scope }: Readonly<{ scope: string }>) { {refreshFailed ? ( - - Could not refresh — showing last synced data. - + Could not refresh — showing last synced data. ) : null} {dashboardStats.isLoading ? ( @@ -233,6 +231,39 @@ 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" + className="min-h-11 justify-center active:opacity-70" + > + 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 ? ( @@ -155,7 +154,7 @@ export function FindingDetailScreen({ scope, findingId }: Readonly { 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 23f343cca7..82c2a5aef1 100644 --- a/apps/mobile/src/components/security-agent/finding-list-screen.tsx +++ b/apps/mobile/src/components/security-agent/finding-list-screen.tsx @@ -146,7 +146,7 @@ export function FindingListScreen({ scope, routeParams }: Readonly )} - contentContainerClassName="gap-3 px-6 pt-4" + contentContainerClassName="grow gap-3 px-6 pt-4" contentContainerStyle={{ paddingBottom }} refreshControl={} onEndReached={() => { @@ -165,11 +165,10 @@ export function FindingListScreen({ scope, routeParams }: Readonly - Reset filters + Clear filters ) : undefined } diff --git a/apps/mobile/src/components/security-agent/finding-row.tsx b/apps/mobile/src/components/security-agent/finding-row.tsx index 56b8c92593..6d4cdd3ada 100644 --- a/apps/mobile/src/components/security-agent/finding-row.tsx +++ b/apps/mobile/src/components/security-agent/finding-row.tsx @@ -96,7 +96,7 @@ function FindingRowQuickAction({ @@ -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} From 3c4275d28fcfe5633908adfbfd2ba4d3515824a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:46:36 +0200 Subject: [PATCH 096/166] fix(mobile): login field labeling and busy feedback --- .../mobile/src/components/login/idle-auth.tsx | 60 +++++++++++-------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/apps/mobile/src/components/login/idle-auth.tsx b/apps/mobile/src/components/login/idle-auth.tsx index a4d786b29b..dcaf482853 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('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" /> + ) : undefined + } + /> + } renderItem={({ item }) => { if (item.type === 'header') { return ( From 8d94fce4d52f4a5f9cc0339dd3ac8d7cd92021a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:53:11 +0200 Subject: [PATCH 102/166] fix(mobile): pending feedback on sync-findings action --- .../src/components/security-agent/dashboard-screen.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/components/security-agent/dashboard-screen.tsx b/apps/mobile/src/components/security-agent/dashboard-screen.tsx index 665ccd3350..8e09ab2a6c 100644 --- a/apps/mobile/src/components/security-agent/dashboard-screen.tsx +++ b/apps/mobile/src/components/security-agent/dashboard-screen.tsx @@ -247,8 +247,12 @@ export function DashboardScreen({ scope }: Readonly<{ scope: string }>) { disabled={triggerSync.isPending} accessibilityRole="button" accessibilityLabel="Sync findings" - className="min-h-11 justify-center active:opacity-70" + 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 Date: Sat, 11 Jul 2026 11:57:36 +0200 Subject: [PATCH 103/166] fix(mobile): version pinning pagination and pin serialization --- .../[instance-id]/settings/version-pin.tsx | 146 +++++++++--------- .../components/kiloclaw/version-pin-row.tsx | 118 ++++++++++++++ .../src/lib/hooks/use-kiloclaw-queries.ts | 9 +- 3 files changed, 194 insertions(+), 79 deletions(-) create mode 100644 apps/mobile/src/components/kiloclaw/version-pin-row.tsx 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 7f873a8904..460748a2ea 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,10 +1,12 @@ -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 { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; import { Button } from '@/components/ui/button'; @@ -18,22 +20,18 @@ import { useKiloClawMyPin, } from '@/lib/hooks/use-kiloclaw-queries'; import { useDetailScreenBottomPadding } from '@/lib/screen-insets'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { parseTimestamp, timeAgo } from '@/lib/utils'; -type VersionItem = NonNullable< - ReturnType['data'] ->['items'][number]; +const PAGE_SIZE = 25; export default function VersionPinScreen() { const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>(); const instanceContext = useInstanceContext(instanceId); const organizationId = instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; - const colors = useThemeColors(); 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(''); @@ -41,6 +39,9 @@ export default function VersionPinScreen() { 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 ( @@ -88,6 +89,10 @@ export default function VersionPinScreen() { const myPin = myPinQuery.data; const latestVersion = latestVersionQuery.data; const versions = availableVersionsQuery.data?.items ?? []; + const pagination = availableVersionsQuery.data?.pagination; + const hasMoreVersions = pagination != null && versions.length < pagination.totalCount; + const isFetchingMoreVersions = + availableVersionsQuery.isFetching && !availableVersionsQuery.isPending; const isPinnedByAdmin = myPin != null && !myPin.pinnedBySelf; @@ -144,74 +149,33 @@ 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} + /> ); } @@ -246,7 +210,13 @@ export default function VersionPinScreen() { )} {!isPinnedByAdmin && ( - )} @@ -284,7 +254,31 @@ export default function VersionPinScreen() { ListEmptyComponent={ availableVersionsQuery.isPending ? ( - ) : undefined + ) : ( + + ) + } + ListFooterComponent={ + hasMoreVersions ? ( + + + + ) : null } className="rounded-lg bg-secondary" /> diff --git a/apps/mobile/src/components/kiloclaw/version-pin-row.tsx b/apps/mobile/src/components/kiloclaw/version-pin-row.tsx new file mode 100644 index 0000000000..148a63139d --- /dev/null +++ b/apps/mobile/src/components/kiloclaw/version-pin-row.tsx @@ -0,0 +1,118 @@ +import { Check } from 'lucide-react-native'; +import { TextInput, View } from 'react-native'; +import Animated, { FadeIn } from 'react-native-reanimated'; + +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { type useKiloClawAvailableVersions } from '@/lib/hooks/use-kiloclaw-queries'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { parseTimestamp, timeAgo } from '@/lib/utils'; + +export type VersionItem = NonNullable< + ReturnType['data'] +>['items'][number]; + +export function VersionPinRow({ + item, + isPinned, + isLatest, + isDraftOpen, + isPinMutating, + isConfirmingThis, + isPinnedByAdmin, + adminPinLabel, + onToggle, + onFocusReason, + onReasonChange, + onConfirm, +}: Readonly<{ + item: VersionItem; + isPinned: boolean; + isLatest: boolean; + isDraftOpen: boolean; + isPinMutating: boolean; + isConfirmingThis: boolean; + isPinnedByAdmin: boolean; + adminPinLabel: string | null; + onToggle: () => void; + onFocusReason: () => void; + onReasonChange: (val: string) => void; + onConfirm: () => void; +}>) { + const colors = useThemeColors(); + const publishedAgo = item.published_at ? timeAgo(parseTimestamp(item.published_at)) : undefined; + const showVariant = item.variant && item.variant !== 'default'; + + return ( + + + + + {item.openclaw_version} + {isLatest && ( + + latest + + )} + + {Boolean(publishedAgo ?? showVariant) && ( + + {[publishedAgo, showVariant ? item.variant : null].filter(Boolean).join(' · ')} + + )} + + {isPinned ? ( + + ) : ( + + )} + + {isDraftOpen && ( + + + Reason (optional) + { + if (val.length <= 500) { + onReasonChange(val); + } + }} + autoCapitalize="sentences" + autoCorrect + multiline + maxLength={500} + editable={!isConfirmingThis} + /> + {isPinnedByAdmin && adminPinLabel && ( + + This replaces the admin-set pin (currently {adminPinLabel}). + + )} + + + + )} + + ); +} diff --git a/apps/mobile/src/lib/hooks/use-kiloclaw-queries.ts b/apps/mobile/src/lib/hooks/use-kiloclaw-queries.ts index 8b19e29c5b..b425f8a403 100644 --- a/apps/mobile/src/lib/hooks/use-kiloclaw-queries.ts +++ b/apps/mobile/src/lib/hooks/use-kiloclaw-queries.ts @@ -1,5 +1,5 @@ import { type inferRouterOutputs, type RootRouter } from '@kilocode/trpc'; -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; import { deriveMobileOnboardingStateFromBilling } from '@/lib/derive-mobile-onboarding-state'; @@ -206,16 +206,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; From 0fbdb92914bf5fb4ed41fb7c832f015c382135ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 11:59:01 +0200 Subject: [PATCH 104/166] fix(mobile): conversation stale-data precedence and offline CTA gating --- .../kilo-chat/bot-send-state.test.ts | 4 +- .../components/kilo-chat/bot-send-state.ts | 11 ++-- .../conversation-route-state.test.ts | 54 +++++++++++++++---- .../kilo-chat/conversation-route-state.ts | 34 ++++++------ .../kilo-chat/message-input-view.tsx | 1 + 5 files changed, 67 insertions(+), 37 deletions(-) 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 93feac3a6c..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,7 +17,7 @@ describe('mobile bot send gate', () => { expect(state.disabled).toBe(true); expect(state.disabledReason).toBe('Waiting for bot status...'); - expect(state.showInstanceCta).toBe(true); + expect(state.showInstanceCta).toBe(false); }); it('blocks sends when the bot is offline or stale', () => { 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 ccffc81146..53ca7207cb 100644 --- a/apps/mobile/src/components/kilo-chat/bot-send-state.ts +++ b/apps/mobile/src/components/kilo-chat/bot-send-state.ts @@ -93,12 +93,11 @@ export function resolveMobileMessageInputAvailability(params: { botDisplay.state === 'unknown' ? 'Waiting for bot status...' : 'Bot is offline. Messages will resume when it reconnects.', - // No time-since-first-unknown tracking here (this fn stays a pure, - // easily-testable state map) — show the CTA as soon as the composer is - // blocked on offline/unknown, since bot status normally arrives within - // seconds of connecting. Add a real "prolonged" threshold if that proves - // too eager in practice. - showInstanceCta: true, + // 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-route-state.test.ts b/apps/mobile/src/components/kilo-chat/conversation-route-state.test.ts index 4be60805ee..1dd0c2e93d 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 @@ -4,7 +4,6 @@ import { describe, expect, it } from 'vitest'; import { getConversationRouteDecision, getConversationRouteErrorMessage, - shouldRenderConversationScreen, } from './conversation-route-state'; describe('getConversationRouteErrorMessage', () => { @@ -21,19 +20,19 @@ describe('getConversationRouteErrorMessage', () => { }); }); -describe('shouldRenderConversationScreen', () => { - it('does not render while the conversation detail is loading', () => { +describe('getConversationRouteDecision', () => { + it('is pending while the conversation detail is loading', () => { expect( - shouldRenderConversationScreen({ + getConversationRouteDecision({ detail: { data: undefined, error: undefined, isError: false }, routeSandboxId: 'sandbox-1', }) - ).toBe(false); + ).toBe('pending'); }); - it('renders after conversation detail loads successfully', () => { + it('is ready after conversation detail loads successfully', () => { expect( - shouldRenderConversationScreen({ + getConversationRouteDecision({ detail: { data: { title: 'Kilo Chat', @@ -47,11 +46,46 @@ describe('shouldRenderConversationScreen', () => { }, routeSandboxId: 'sandbox-1', }) - ).toBe(true); + ).toBe('ready'); }); -}); -describe('getConversationRouteDecision', () => { + it('stays ready when a background refetch fails but cached data is retained', () => { + expect( + 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('ready'); + }); + + it('redirects even with cached data when the background refetch confirms not-found/forbidden', () => { + expect( + getConversationRouteDecision({ + detail: { + data: { + title: 'Kilo Chat', + members: [ + { id: 'user-1', kind: 'user' }, + { id: 'bot:kiloclaw:sandbox-1', kind: 'bot' }, + ], + }, + error: new KiloChatApiError(403, {}), + isError: true, + }, + routeSandboxId: 'sandbox-1', + }) + ).toBe('not-found'); + }); it('rejects conversations that belong to a different sandbox route', () => { expect( getConversationRouteDecision({ 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 c2cddde7d0..b952cd4d6c 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-route-state.ts +++ b/apps/mobile/src/components/kilo-chat/conversation-route-state.ts @@ -30,26 +30,22 @@ export function getConversationRouteDecision({ detail: ConversationRouteDetailState; routeSandboxId: string; }): ConversationRouteDecision { - if (detail.isError) { - // Only a confirmed not-found/forbidden response should redirect away — - // transport/server errors are retryable in place (see T2.9). - return isConversationNotFoundError(detail.error) ? 'not-found' : 'retryable-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/message-input-view.tsx b/apps/mobile/src/components/kilo-chat/message-input-view.tsx index a864e20651..52053a8939 100644 --- a/apps/mobile/src/components/kilo-chat/message-input-view.tsx +++ b/apps/mobile/src/components/kilo-chat/message-input-view.tsx @@ -116,6 +116,7 @@ export function MessageInputView({ hitSlop={8} accessibilityRole="button" accessibilityLabel="Open instance" + className="active:opacity-70" > Open instance From 71b7548dd758ad93e9ae1502ffa16b2a94e39729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 12:01:22 +0200 Subject: [PATCH 105/166] fix(mobile): single feedback owner for kilo-pass purchase errors --- .../kilo-pass-subscription-screen.tsx | 2 + .../kilo-pass/restore-purchases-button.tsx | 4 +- .../use-store-kilo-pass-purchase.test.tsx | 82 +++++++++++++++++- .../kilo-pass/use-store-kilo-pass-purchase.ts | 86 ++++++++++++------- 4 files changed, 140 insertions(+), 34 deletions(-) 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 f97cb5fd21..a3ebd89fc9 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 @@ -60,6 +60,8 @@ export function KiloPassSubscriptionScreen() { onCompleted: () => { ensureProfileAfterKiloPassPurchase(router); }, + // This screen owns the inline error banner — don't double up with a toast. + suppressToast: true, }); }; 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 3778ba558c..f179e5cf18 100644 --- a/apps/mobile/src/components/kilo-pass/restore-purchases-button.tsx +++ b/apps/mobile/src/components/kilo-pass/restore-purchases-button.tsx @@ -28,7 +28,9 @@ export function RestorePurchasesButton({ onResult }: Readonly { void Haptics.selectionAsync(); void (async () => { - const result = await restorePurchases(); + // A caller that renders its own feedback (onResult) also owns the error banner — + // suppress the hook's toast so failures aren't reported twice. + const result = await restorePurchases({ suppressToast: onResult !== undefined }); if (onResult) { onResult(result); return; 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..a436401a50 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,6 +6,7 @@ import { toast } from 'sonner-native'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createAppStoreKiloPassPurchaseActions, + resetPurchaseErrorToastDedup, StoreKiloPassPurchaseProvider, } from './use-store-kilo-pass-purchase'; import { type AppStoreKiloPassProduct } from './store-products'; @@ -101,11 +102,14 @@ type StoreKiloPassPurchaseContextValue = { appStoreOwnershipPreflight: 'owned-by-another-account' | null; purchase: ( product: AppStoreKiloPassProduct, - options?: { onCompleted?: () => void } + options?: { onCompleted?: () => void; suppressToast?: boolean } ) => Promise; - restorePurchases: () => Promise<'restored' | 'empty' | 'failed'>; + restorePurchases: (options?: { + suppressToast?: boolean; + }) => Promise<'restored' | 'empty' | 'failed'>; isPending: boolean; isRestoringPurchases: boolean; + errorMessage: string | null; }; type ReactInternals = { @@ -271,6 +275,7 @@ function createPurchase(overrides: Partial = {}): Purchase { beforeEach(() => { vi.clearAllMocks(); + resetPurchaseErrorToastDedup(); mockedIap.availablePurchases = []; mockedIap.connected = false; mockedIap.finishTransaction.mockResolvedValue(undefined); @@ -348,6 +353,22 @@ describe('createAppStoreKiloPassPurchaseActions', () => { expect(showError).toHaveBeenCalledWith('Could not connect to App Store'); }); + it('tells showError to suppress its toast when the caller owns inline feedback', async () => { + const showError = vi.fn(); + const actions = createActions({ + requestPurchase: vi.fn().mockRejectedValue(new Error('Could not connect to App Store')), + showError: (message, options) => { + showError(message, options); + }, + }); + + await actions.purchase(product, { suppressToast: true }); + + expect(showError).toHaveBeenCalledWith('Could not connect to App Store', { + suppressToast: true, + }); + }); + it('does not show an error when the user cancels the App Store purchase sheet', async () => { const showError = vi.fn(); const actions = createActions({ @@ -753,6 +774,24 @@ describe('createAppStoreKiloPassPurchaseActions', () => { expect(result).toBe('failed'); expect(showError).toHaveBeenCalledWith('Failed to restore purchases. Try again.'); }); + + it('tells showError to suppress its toast when restore is driven by an inline-feedback caller', async () => { + const showError = vi.fn(); + const actions = createActions({ + getAvailablePurchases: vi.fn(), + restorePurchases: vi.fn().mockRejectedValue(new Error('StoreKit unavailable')), + showError: (message, options) => { + showError(message, options); + }, + }); + + const result = await actions.restorePurchases({ suppressToast: true }); + + expect(result).toBe('failed'); + expect(showError).toHaveBeenCalledWith('Failed to restore purchases. Try again.', { + suppressToast: true, + }); + }); }); describe('StoreKiloPassPurchaseProvider', () => { @@ -816,6 +855,45 @@ 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 when the subscription screen owns inline feedback', async () => { + const provider = renderStoreKiloPassPurchaseProvider(); + + const initialValue = provider.render(); + await initialValue.purchase(product, { suppressToast: true }); + + mockedIap.handlers?.onPurchaseError(new Error('Inline banner check failed')); + const releasedValue = provider.render(); + + expect(toast.error).not.toHaveBeenCalled(); + expect(releasedValue.errorMessage).toBe('Inline banner check failed'); + }); + + it('suppresses the restore-error toast when the caller passes suppressToast', async () => { + mockedIap.restorePurchases.mockRejectedValue(new Error('StoreKit unavailable')); + const provider = renderStoreKiloPassPurchaseProvider(); + + const initialValue = provider.render(); + const result = await initialValue.restorePurchases({ suppressToast: true }); + const releasedValue = provider.render(); + + expect(result).toBe('failed'); + expect(toast.error).not.toHaveBeenCalled(); + expect(releasedValue.errorMessage).toBe('Failed to restore purchases. Try again.'); + }); + 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 2c0dff9d9e..e288520ef0 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 @@ -72,10 +72,15 @@ type AppStoreKiloPassPurchaseActionsDeps = { invalidateAfterCompletion: () => Promise | void; onPurchaseCompleted?: () => void; setPendingPurchaseCompletedCallback?: (callback: (() => void) | null) => void; - showError: (message: string) => void; + showError: (message: string, options?: ShowErrorOptions) => void; }; -type StoreKiloPassPurchaseOptions = { +type ShowErrorOptions = { + /** Skip the toast — an inline consumer (e.g. the subscription screen) already owns feedback. */ + suppressToast?: boolean; +}; + +type StoreKiloPassPurchaseOptions = ShowErrorOptions & { onCompleted?: () => void; }; @@ -87,7 +92,7 @@ type StoreKiloPassPurchaseContextValue = { product: AppStoreKiloPassProduct, options?: StoreKiloPassPurchaseOptions ) => Promise; - restorePurchases: () => Promise; + restorePurchases: (options?: ShowErrorOptions) => Promise; isPending: boolean; isRestoringPurchases: boolean; /** Last purchase/restore failure message, for screens that render it inline. */ @@ -107,7 +112,7 @@ export function resetPurchaseErrorToastDedup() { lastPurchaseErrorToast = null; } -type PurchaseCompletionOptions = { +type PurchaseCompletionOptions = ShowErrorOptions & { invalidateAfterCompletion?: boolean; notifyErrors?: boolean; }; @@ -214,7 +219,7 @@ export function createAppStoreKiloPassPurchaseActions(deps: AppStoreKiloPassPurc options: PurchaseCompletionOptions ) { if (!result.completed && result.errorMessage && (options.notifyErrors ?? true)) { - deps.showError(result.errorMessage); + deps.showError(result.errorMessage, { suppressToast: options.suppressToast }); } } @@ -304,7 +309,7 @@ export function createAppStoreKiloPassPurchaseActions(deps: AppStoreKiloPassPurc 'Failed to start App Store purchase.' ); if (message) { - deps.showError(message); + deps.showError(message, { suppressToast: options.suppressToast }); } deps.setPendingPurchaseCompletedCallback?.(null); return false; @@ -312,13 +317,17 @@ export function createAppStoreKiloPassPurchaseActions(deps: AppStoreKiloPassPurc }, handlePurchaseSuccess, recoverPurchases, - restorePurchases: async (): Promise => { + restorePurchases: async ( + options: ShowErrorOptions = {} + ): Promise => { try { await deps.restorePurchases(); const availablePurchases = await deps.getAvailablePurchases(); const enabledAppleProductIds = await getEnabledAppleProductIdsForRestore(); if (enabledAppleProductIds.length === 0) { - deps.showError(RESTORE_PURCHASES_ERROR_MESSAGE); + deps.showError(RESTORE_PURCHASES_ERROR_MESSAGE, { + suppressToast: options.suppressToast, + }); return 'failed'; } @@ -332,10 +341,11 @@ export function createAppStoreKiloPassPurchaseActions(deps: AppStoreKiloPassPurc const completedPurchases = await recoverPurchases(kiloPassPurchases, { enabledAppleProductIds, notifyErrors: true, + suppressToast: options.suppressToast, }); return completedPurchases.length > 0 ? 'restored' : 'failed'; } catch { - deps.showError(RESTORE_PURCHASES_ERROR_MESSAGE); + deps.showError(RESTORE_PURCHASES_ERROR_MESSAGE, { suppressToast: options.suppressToast }); return 'failed'; } }, @@ -353,7 +363,7 @@ export function StoreKiloPassPurchaseProvider({ children }: { children: ReactNod }, []); const recoveredPurchaseIdsRef = useRef(new Set()); const recoveryInFlightPurchaseIdsRef = useRef(new Set()); - const activePurchaseRequestRef = useRef<{ sku: string } | null>(null); + const activePurchaseRequestRef = useRef<{ sku: string; suppressToast: boolean } | null>(null); const pendingPurchaseCompletedCallbackRef = useRef<(() => void) | null>(null); const completeAppStorePurchase = useMutation( trpc.kiloPass.completeAppStorePurchase.mutationOptions() @@ -384,13 +394,16 @@ export function StoreKiloPassPurchaseProvider({ children }: { children: ReactNod const actionsRef = useIAP({ onPurchaseError: error => { + const suppressToast = activePurchaseRequestRef.current?.suppressToast ?? false; pendingPurchaseCompletedCallbackRef.current = null; releasePurchaseRequest(); // A null message means the user cancelled — not a failure. const message = getKiloPassPurchaseErrorMessage(error, error.message); if (message) { captureEvent(KILO_PASS_PURCHASE_FAILED_EVENT); - showDedupedPurchaseError(message); + if (!suppressToast) { + showDedupedPurchaseError(message); + } setErrorMessage(message); } }, @@ -404,9 +417,10 @@ export function StoreKiloPassPurchaseProvider({ children }: { children: ReactNod return; } + const suppressToast = activePurchaseRequestRef.current.suppressToast; void (async () => { try { - await actions.handlePurchaseSuccess(purchase); + await actions.handlePurchaseSuccess(purchase, { suppressToast }); } finally { releasePurchaseRequest(); } @@ -458,8 +472,10 @@ export function StoreKiloPassPurchaseProvider({ children }: { children: ReactNod setPendingPurchaseCompletedCallback: onCompleted => { pendingPurchaseCompletedCallbackRef.current = onCompleted; }, - showError: message => { - showDedupedPurchaseError(message); + showError: (message, showErrorOptions) => { + if (!showErrorOptions?.suppressToast) { + showDedupedPurchaseError(message); + } setErrorMessage(message); }, }), @@ -480,7 +496,10 @@ export function StoreKiloPassPurchaseProvider({ children }: { children: ReactNod return; } - activePurchaseRequestRef.current = { sku: product.appleProductId }; + activePurchaseRequestRef.current = { + sku: product.appleProductId, + suppressToast: options.suppressToast ?? false, + }; setIsRequestingPurchase(true); setErrorMessage(null); captureEvent(KILO_PASS_PURCHASE_STARTED_EVENT); @@ -497,23 +516,28 @@ export function StoreKiloPassPurchaseProvider({ children }: { children: ReactNod [actions, completeAppStorePurchase.isPending, releasePurchaseRequest] ); - const restorePurchases = useCallback(async (): Promise => { - if ( - activePurchaseRequestRef.current || - isRestoringPurchases || - completeAppStorePurchase.isPending - ) { - return 'failed'; - } + const restorePurchases = useCallback( + async ( + options: { suppressToast?: boolean } = {} + ): Promise => { + if ( + activePurchaseRequestRef.current || + isRestoringPurchases || + completeAppStorePurchase.isPending + ) { + return 'failed'; + } - setIsRestoringPurchases(true); - setErrorMessage(null); - try { - return await actions.restorePurchases(); - } finally { - setIsRestoringPurchases(false); - } - }, [actions, completeAppStorePurchase.isPending, isRestoringPurchases]); + setIsRestoringPurchases(true); + setErrorMessage(null); + try { + return await actions.restorePurchases(options); + } finally { + setIsRestoringPurchases(false); + } + }, + [actions, completeAppStorePurchase.isPending, isRestoringPurchases] + ); useEffect(() => { if (Platform.OS !== 'ios' || !connected) { From a7cd596f81f922e4f9a6d03207357b2e4810b0a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 12:01:43 +0200 Subject: [PATCH 106/166] fix(mobile): google settings and device pairing feedback --- .../[instance-id]/settings/device-pairing.tsx | 70 +++++++++++------- .../[instance-id]/settings/google.tsx | 73 +++++++++++++++++-- 2 files changed, 112 insertions(+), 31 deletions(-) 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 17e2068475..3e169c5737 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 @@ -1,6 +1,6 @@ import { useQueryClient } from '@tanstack/react-query'; import { MessageSquare, Monitor, RefreshCw } from 'lucide-react-native'; -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { Alert, Pressable, ScrollView, View } from 'react-native'; import Animated, { Easing, @@ -48,6 +48,8 @@ export default function DevicePairingScreen() { const devicePairingQuery = useKiloClawDevicePairing(organizationId); const mutations = useKiloClawMutations(organizationId); const paddingBottom = useDetailScreenBottomPadding(); + const [pendingChannelKey, setPendingChannelKey] = useState(null); + const [pendingDeviceRequestId, setPendingDeviceRequestId] = useState(null); const isLoading = pairingQuery.isPending || devicePairingQuery.isPending; @@ -73,7 +75,7 @@ export default function DevicePairingScreen() { onPress={() => { void handleRefresh(); }} - className="p-2" + className="p-2 active:opacity-70" > @@ -133,7 +135,11 @@ export default function DevicePairingScreen() { { text: 'Approve', onPress: () => { - mutations.approvePairingRequest.mutate({ channel, code }); + setPendingChannelKey(`${channel}-${code}`); + mutations.approvePairingRequest.mutate( + { channel, code }, + { onSettled: () =>{ setPendingChannelKey(null); } } + ); }, }, ] @@ -146,7 +152,11 @@ export default function DevicePairingScreen() { { text: 'Approve', onPress: () => { - mutations.approveDevicePairingRequest.mutate({ requestId }); + setPendingDeviceRequestId(requestId); + mutations.approveDevicePairingRequest.mutate( + { requestId }, + { onSettled: () =>{ setPendingDeviceRequestId(null); } } + ); }, }, ]); @@ -186,6 +196,7 @@ export default function DevicePairingScreen() { {channelRequests.map((request, index) => { const ChannelIcon = CATALOG_ICONS[request.channel]; + const isThisPending = pendingChannelKey === `${request.channel}-${request.code}`; return ( {index > 0 && } @@ -210,6 +221,8 @@ export default function DevicePairingScreen() { - - - ))} + ); + })} )} 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 aa6fd04a7d..e64ba47003 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,5 +1,5 @@ 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'; @@ -19,6 +19,7 @@ import { 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() { @@ -29,8 +30,10 @@ export default function GoogleScreen() { 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; @@ -101,13 +104,33 @@ 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: () => { + mutations.restartMachine.mutate(undefined, { + onSuccess: () => { + setShowRedeployPrompt(false); + }, + }); + }, + }, + ]); + } + return ( @@ -142,13 +165,48 @@ export default function GoogleScreen() { {!isConnected && ( + {showRedeployPrompt && ( + + + Google account disconnected. Redeploy your instance to apply the change. + + + + )} 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 && ( @@ -185,8 +243,13 @@ export default function GoogleScreen() { - From 1d13d97339b6f664e8cbb758c8f417d4fff9e951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 12:02:49 +0200 Subject: [PATCH 107/166] fix(mobile): actionable changelog requirements --- .../kiloclaw/[instance-id]/changelog.tsx | 31 +++++++++++++--- .../components/kiloclaw/changelog-list.tsx | 35 +++++++++++++++++-- 2 files changed, 60 insertions(+), 6 deletions(-) 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 517669fdfb..93cef191de 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/changelog.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/changelog.tsx @@ -1,7 +1,7 @@ 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'; @@ -11,7 +11,7 @@ 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 { useKiloClawChangelog, useKiloClawMutations } from '@/lib/hooks/use-kiloclaw-queries'; import { useDetailScreenBottomPadding } from '@/lib/screen-insets'; export default function ChangelogScreen() { @@ -20,9 +20,27 @@ export default function ChangelogScreen() { const organizationId = instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; const changelogQuery = useKiloClawChangelog(organizationId); + const mutations = useKiloClawMutations(organizationId); + const router = useRouter(); const entries = changelogQuery.data; const paddingBottom = useDetailScreenBottomPadding(); + 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); + } + function renderContent() { if (changelogQuery.isPending) { return ( @@ -54,7 +72,12 @@ export default function ChangelogScreen() { } return ( - + ); } diff --git a/apps/mobile/src/components/kiloclaw/changelog-list.tsx b/apps/mobile/src/components/kiloclaw/changelog-list.tsx index 3c42bc1200..8dcbc84a39 100644 --- a/apps/mobile/src/components/kiloclaw/changelog-list.tsx +++ b/apps/mobile/src/components/kiloclaw/changelog-list.tsx @@ -1,8 +1,10 @@ -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 { 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]; @@ -25,7 +27,19 @@ const DEPLOY_HINTS: Record) { +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 +62,23 @@ export function ChangelogList({ entries }: Readonly<{ entries: ChangelogEntry[] )} {entry.description} + {entry.deployHint === 'redeploy_required' && ( + + )} + {entry.deployHint === 'upgrade_required' && ( + + )} ); })} From 093e899c2861d5ce08e78b8218c8328939ecead6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 12:03:17 +0200 Subject: [PATCH 108/166] fix(mobile): kilo-chat message actions, links, and favorites feedback --- .../components/agents/markdown-link.test.ts | 27 +++++++ .../src/components/agents/markdown-link.ts | 22 +++++ .../src/components/agents/markdown-text.tsx | 10 ++- .../agents/model-picker-content.tsx | 39 +++++---- .../hooks/use-conversation-message-actions.ts | 10 ++- .../use-conversation-message-controller.ts | 24 ++++++ .../kilo-chat/hooks/use-mark-read.ts | 7 +- .../kilo-chat/message-actions.test.ts | 15 ++++ .../components/kilo-chat/message-actions.ts | 6 ++ .../kilo-chat/message-attachment.tsx | 12 ++- .../components/kilo-chat/message-bubble.tsx | 1 + .../kilo-chat/message-image-preview-modal.tsx | 16 ++++ .../kilo-chat/message-presentation.test.ts | 54 +++++++++++++ .../kilo-chat/message-presentation.ts | 11 +++ .../src/lib/hooks/use-model-preferences.ts | 80 +++++++++++-------- 15 files changed, 280 insertions(+), 54 deletions(-) create mode 100644 apps/mobile/src/components/agents/markdown-link.test.ts create mode 100644 apps/mobile/src/components/agents/markdown-link.ts 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-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/model-picker-content.tsx b/apps/mobile/src/components/agents/model-picker-content.tsx index 14d9726419..25f6dea1bc 100644 --- a/apps/mobile/src/components/agents/model-picker-content.tsx +++ b/apps/mobile/src/components/agents/model-picker-content.tsx @@ -1,6 +1,6 @@ import * as Haptics from 'expo-haptics'; import { useFocusEffect, useRouter } from 'expo-router'; -import { Info, Search } from 'lucide-react-native'; +import { AlertCircle, Info, Search } from 'lucide-react-native'; import { useCallback, useMemo, useRef, useState } from 'react'; import { FlatList, TextInput, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -28,7 +28,7 @@ export function ModelPickerContent() { const router = useRouter(); const colors = useThemeColors(); const { bottom } = useSafeAreaInsets(); - const { favorites, toggleFavorite } = useModelPreferences(undefined); + const { favorites, favoritesError, toggleFavorite } = useModelPreferences(undefined); const favoriteIds = useMemo(() => new Set(favorites), [favorites]); const [search, setSearch] = useState(''); const [bridge, setBridge] = useState(() => getModelPickerBridge()); @@ -84,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] @@ -160,18 +161,26 @@ export function ModelPickerContent() { keyboardDismissMode="on-drag" contentContainerStyle={{ paddingBottom: bottom }} ListHeaderComponent={ - - - + + + + + + {favoritesError ? ( + + + {favoritesError} + + ) : null} } ListEmptyComponent={ 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..3bf1455b17 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 @@ -16,6 +16,7 @@ import { captureEvent, MESSAGE_SENT_EVENT } from '@/lib/analytics/posthog'; import { resolveMobileMessageInputAvailability } from '../bot-send-state'; import { type MessageInputSubmitControls } from '../message-input-state'; import { + buildRetrySendContent, buildSendMessageVariables, createSendMessageClientId, getEditableAttachmentBlocks, @@ -93,12 +94,35 @@ export function useConversationMessageController({ setRemovedEditAttachmentIds([]); }, []); + const handleRetrySend = useCallback( + (message: Message) => { + sendMutation.mutate( + buildSendMessageVariables({ + conversationId, + content: buildRetrySendContent(message), + clientId: createSendMessageClientId(), + inReplyToMessageId: message.inReplyToMessageId ?? undefined, + }), + { + onSuccess: () => { + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + }, + onError: err => { + toast.error(formatKiloChatError(err, 'Failed to send message')); + }, + } + ); + }, + [conversationId, sendMutation] + ); + 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-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/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..4e789fc7c6 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(); 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-image-preview-modal.tsx b/apps/mobile/src/components/kilo-chat/message-image-preview-modal.tsx index ed7d7ba7b7..c47946de29 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) { @@ -53,6 +57,18 @@ export function MessageImagePreviewModal({ {uri ? : null} + {shareError ? ( + + + + {shareError} + + + + ) : null} ); 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..c118a92b09 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,14 @@ +/* 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 { + buildRetrySendContent, buildSendMessageVariables, canCopyMessage, + canRetryFailedMessage, canShowReactionPills, canToggleReaction, createSendMessageClientId, @@ -235,6 +240,55 @@ 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('buildRetrySendContent', () => { + it('rebuilds a single joined text block plus attachments', () => { + const attachment = { + type: 'attachment', + attachmentId: '01HV0000000000000000000001', + mimeType: 'image/png', + size: 123, + filename: 'photo.png', + } as const; + + expect( + buildRetrySendContent( + message({ + content: [{ type: 'text', text: 'hello' }, attachment], + deliveryFailed: true, + }) + ) + ).toEqual([{ type: 'text', text: 'hello' }, attachment]); + }); + + it('drops an empty text block, keeping attachment-only content', () => { + const attachment = { + type: 'attachment', + attachmentId: '01HV0000000000000000000001', + mimeType: 'image/png', + size: 123, + filename: 'photo.png', + } as const; + + expect(buildRetrySendContent(message({ content: [attachment], deliveryFailed: true }))).toEqual( + [attachment] + ); + }); +}); + 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..7c64b6fbc6 100644 --- a/apps/mobile/src/components/kilo-chat/message-presentation.ts +++ b/apps/mobile/src/components/kilo-chat/message-presentation.ts @@ -92,6 +92,17 @@ export function isMessageEdited(message: Message): boolean { return !message.deleted && message.clientUpdatedAt !== null; } +export function canRetryFailedMessage(message: Message): boolean { + return !message.deleted && message.deliveryFailed; +} + +/** Reconstructs the original send content from a delivery-failed message, for retry. */ +export function buildRetrySendContent(message: Message): InputContentBlock[] { + const text = contentBlocksToText(message.content).trim(); + const textBlocks: InputContentBlock[] = text.length > 0 ? [{ type: 'text', text }] : []; + return [...textBlocks, ...getEditableAttachmentBlocks(message)]; +} + function firstDisplayValue(values: readonly (string | null | undefined)[]): string | null { for (const value of values) { const trimmed = value?.trim(); 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, From 13cc9a3a438ebfe5f1aae172851dba44c84266e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 12:04:17 +0200 Subject: [PATCH 109/166] fix(mobile): reaction picker modal behavior --- .../message-reaction-picker-sheet.tsx | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) 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} - - + + ); } From 215536856edb4df87cc37156a16262bda9bc5d9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 12:04:46 +0200 Subject: [PATCH 110/166] fix(mobile): single accessible control for reasoning toggle --- .../src/components/agents/reasoning-settings-modal.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/components/agents/reasoning-settings-modal.tsx b/apps/mobile/src/components/agents/reasoning-settings-modal.tsx index 151e8c542c..f0a85bbaaa 100644 --- a/apps/mobile/src/components/agents/reasoning-settings-modal.tsx +++ b/apps/mobile/src/components/agents/reasoning-settings-modal.tsx @@ -31,6 +31,10 @@ export function ReasoningSettingsModal({ }} > 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 +43,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 +61,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'} /> From ac4db300b0131c89e39a9ed8fda3d441a4d8d447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 12:06:55 +0200 Subject: [PATCH 111/166] fix(mobile): surface secure-store preference write failures --- .../lib/hooks/secure-store-preference.test.ts | 91 +++++++++++++++++++ .../src/lib/hooks/secure-store-preference.ts | 14 ++- 2 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/lib/hooks/secure-store-preference.test.ts 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'); } }; From 90eb2a5fb4e9db95370f76590a44f81fad47a919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 12:08:30 +0200 Subject: [PATCH 112/166] fix(mobile): single creation CTA on empty surfaces --- .../agents/session-list-header-actions.tsx | 26 +++++++++------ .../components/agents/session-list-screen.tsx | 4 ++- .../kilo-chat/conversation-list-screen.tsx | 32 +++++++++++-------- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/apps/mobile/src/components/agents/session-list-header-actions.tsx b/apps/mobile/src/components/agents/session-list-header-actions.tsx index e246d28d68..6648cdbd92 100644 --- a/apps/mobile/src/components/agents/session-list-header-actions.tsx +++ b/apps/mobile/src/components/agents/session-list-header-actions.tsx @@ -5,12 +5,16 @@ 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) { @@ -18,16 +22,18 @@ export function SessionListHeaderActions({ return ( - - - + {showNewSession ? ( + + + + ) : null} 0 || projectFilter.length > 0; + const hasAnySessions = storedSessions.length > 0 || activeSessions.length > 0; const handleClearQuery = useCallback(() => { handleClearSearch(); @@ -263,6 +264,7 @@ export function AgentSessionListScreen() { headerRight={ { router.push(getNewAgentSessionPath(organizationId) as Href); }} @@ -289,7 +291,7 @@ export function AgentSessionListScreen() { 0 || activeSessions.length > 0} + hasAnySessions={hasAnySessions} isLoading={isLoading || !ready} isSearchPending={isSearchPending} isError={contentIsError} 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 4f9738d529..f2626014cf 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx @@ -279,20 +279,24 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { } /> - - {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 ? ( + + ) : ( + + )} + + )} ); } From 1b02323787eb5f9dae843615f5924ff8b7dfb73b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 15:20:22 +0200 Subject: [PATCH 113/166] fix(mobile): retry-send resolves failed original; session-filter write feedback --- .../use-conversation-message-controller.ts | 10 +++- .../use-persisted-agent-session-filters.ts | 6 ++- .../kilo-chat-hooks/src/use-messages.test.ts | 49 +++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) 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 3bf1455b17..ed85ed2753 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,6 @@ +import { useQueryClient } from '@tanstack/react-query'; import * as Haptics from 'expo-haptics'; -import { useEditMessage } from '@kilocode/kilo-chat-hooks'; +import { messagesKey, removeMessageFromCache, useEditMessage } from '@kilocode/kilo-chat-hooks'; import { buildMessageEditContent, contentBlocksToText, @@ -59,6 +60,7 @@ export function useConversationMessageController({ const [replyingTo, setReplyingTo] = useState(null); const [scrollToNewestRequest, setScrollToNewestRequest] = useState(0); + const queryClient = useQueryClient(); const sendMutation = useSendMessage(client, conversationId, currentUserId); const editMessage = useEditMessage(client, conversationId); const editingTextValue = useMemo( @@ -105,6 +107,10 @@ export function useConversationMessageController({ }), { onSuccess: () => { + // The resend settled a brand-new message row; drop the original + // failed row so retrying doesn't leave a permanent "Not + // delivered" duplicate alongside the delivered message. + removeMessageFromCache(queryClient, messagesKey(conversationId), message.id); void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); }, onError: err => { @@ -113,7 +119,7 @@ export function useConversationMessageController({ } ); }, - [conversationId, sendMutation] + [conversationId, queryClient, sendMutation] ); const messageActions = useConversationMessageActions({ 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/packages/kilo-chat-hooks/src/use-messages.test.ts b/packages/kilo-chat-hooks/src/use-messages.test.ts index d7ac9f8928..3db38a854b 100644 --- a/packages/kilo-chat-hooks/src/use-messages.test.ts +++ b/packages/kilo-chat-hooks/src/use-messages.test.ts @@ -616,6 +616,55 @@ describe('send message cache settlement', () => { expect(queryClient.getQueryState(queryKey)?.isInvalidated).toBe(true); }); + it('leaves exactly one message when a retried send settles after the original failed row', () => { + const queryClient = new QueryClient(); + const queryKey = messagesKey('01KQK8Y0000000000000000003'); + const failedOriginal = message({ + id: '01KQK8Y2222222222222222224', + senderId: 'user-current', + content: textContent('never delivered'), + deliveryFailed: true, + }); + queryClient.setQueryData(queryKey, { + pageParams: [undefined], + pages: [messagePage([failedOriginal])], + }); + + const pendingId = 'pending-01KQK8Y1111111111111111113'; + const retryOptimisticMessage = message({ + id: pendingId, + senderId: 'user-current', + content: textContent('never delivered'), + }); + const context = applyOptimisticSendMessageToCache( + queryClient, + queryKey, + pendingId, + retryOptimisticMessage + ); + const resentMessage = message({ + id: '01KQK8Y3333333333333333334', + senderId: 'user-current', + content: textContent('never delivered'), + }); + settleSendMessageSuccess( + queryClient, + { + messageId: resentMessage.id, + clientId: '01KQK8Y1111111111111111113', + message: resentMessage, + }, + context + ); + + // Mirrors handleRetrySend's onSuccess cache surgery: drop the original + // failed row now that the resend has settled successfully. + removeMessageFromCache(queryClient, queryKey, failedOriginal.id); + + const result = queryClient.getQueryData(queryKey); + expect(result?.pages.flatMap(page => page.messages)).toEqual([resentMessage]); + }); + it('creates the first page for message.created events when the messages cache is cold', () => { const queryClient = new QueryClient(); const queryKey = messagesKey('01KQK8Z0000000000000000000'); From c69bdce912fb207a34c97dc97feacb07dfbfee18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 15:25:16 +0200 Subject: [PATCH 114/166] fix(mobile): version-pin pagination bounds and stale-data preservation --- .../[instance-id]/settings/device-pairing.tsx | 12 +- .../[instance-id]/settings/google.tsx | 2 + .../[instance-id]/settings/model-list.tsx | 1 + .../[instance-id]/settings/version-pin.tsx | 134 +++++++++--------- .../components/kiloclaw/changelog-list.tsx | 6 +- .../kiloclaw/version-pin-status-card.tsx | 77 ++++++++++ 6 files changed, 165 insertions(+), 67 deletions(-) create mode 100644 apps/mobile/src/components/kiloclaw/version-pin-status-card.tsx 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 3e169c5737..4d92d3e2b4 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 @@ -138,7 +138,11 @@ export default function DevicePairingScreen() { setPendingChannelKey(`${channel}-${code}`); mutations.approvePairingRequest.mutate( { channel, code }, - { onSettled: () =>{ setPendingChannelKey(null); } } + { + onSettled: () => { + setPendingChannelKey(null); + }, + } ); }, }, @@ -155,7 +159,11 @@ export default function DevicePairingScreen() { setPendingDeviceRequestId(requestId); mutations.approveDevicePairingRequest.mutate( { requestId }, - { onSettled: () =>{ setPendingDeviceRequestId(null); } } + { + onSettled: () => { + setPendingDeviceRequestId(null); + }, + } ); }, }, 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 e64ba47003..4707b8f572 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 @@ -12,6 +12,7 @@ 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 { captureEvent, INSTANCE_ACTION_EVENT } from '@/lib/analytics/posthog'; import { useInstanceContext } from '@/lib/hooks/use-instance-context'; import { useKiloClawGoogleSetup, @@ -121,6 +122,7 @@ export default function GoogleScreen() { { text: 'Redeploy', onPress: () => { + captureEvent(INSTANCE_ACTION_EVENT, { surface: 'claw', action: 'redeploy' }); mutations.restartMachine.mutate(undefined, { onSuccess: () => { setShowRedeployPrompt(false); 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 4a1042b42f..67d1b3c116 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 @@ -217,6 +217,7 @@ export default function ModelListScreen() { ? `No results for "${searchFilter}"` : 'Models will appear here once available.' } + placement="top" action={ searchFilter ? ( + + ); + } + if (hasMoreVersions && !isAtLimitCap) { + return ( + + + + ); + } + if (hasMoreVersions && isAtLimitCap) { + return ( + + + Showing latest 100 versions + + + ); + } + return null; + } + return ( @@ -195,53 +255,14 @@ export default function VersionPinScreen() { 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 && ( @@ -264,22 +285,7 @@ export default function VersionPinScreen() { /> ) } - ListFooterComponent={ - hasMoreVersions ? ( - - - - ) : null - } + ListFooterComponent={renderFooter()} className="rounded-lg bg-secondary" /> diff --git a/apps/mobile/src/components/kiloclaw/changelog-list.tsx b/apps/mobile/src/components/kiloclaw/changelog-list.tsx index 8dcbc84a39..d5ea1a427a 100644 --- a/apps/mobile/src/components/kiloclaw/changelog-list.tsx +++ b/apps/mobile/src/components/kiloclaw/changelog-list.tsx @@ -3,6 +3,7 @@ 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'; @@ -67,7 +68,10 @@ export function ChangelogList({ size="sm" variant="outline" loading={isRedeploying} - onPress={onRedeploy} + onPress={() => { + captureEvent(INSTANCE_ACTION_EVENT, { surface: 'claw', action: 'redeploy' }); + onRedeploy(); + }} className="flex-row gap-1.5 self-start" > {!isRedeploying && } 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..ca245da14a --- /dev/null +++ b/apps/mobile/src/components/kiloclaw/version-pin-status-card.tsx @@ -0,0 +1,77 @@ +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} + + )} + + )} + + ); +} From de1cb987821fae7d765d4fab7cdf14e36da9d0ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 15:30:07 +0200 Subject: [PATCH 115/166] chore(mobile): fix home-screen test mocks and prune dead code-reviewer exports --- .../code-reviewer/platform-overview-rows.ts | 2 +- apps/mobile/src/components/home/home-screen.test.ts | 3 +++ apps/mobile/src/lib/code-reviewer-status.ts | 2 +- apps/mobile/src/lib/hooks/use-code-reviewer.ts | 11 ++--------- apps/mobile/src/lib/hooks/use-reviewer-permission.ts | 12 ------------ 5 files changed, 7 insertions(+), 23 deletions(-) diff --git a/apps/mobile/src/components/code-reviewer/platform-overview-rows.ts b/apps/mobile/src/components/code-reviewer/platform-overview-rows.ts index b550f46a30..fb04bf8479 100644 --- a/apps/mobile/src/components/code-reviewer/platform-overview-rows.ts +++ b/apps/mobile/src/components/code-reviewer/platform-overview-rows.ts @@ -13,7 +13,7 @@ import { type ModelOption } from '@/lib/hooks/use-available-models'; const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1); -export type OverviewRow = { +type OverviewRow = { field: string; icon: LucideIcon; title: string; diff --git a/apps/mobile/src/components/home/home-screen.test.ts b/apps/mobile/src/components/home/home-screen.test.ts index cba1abafc5..5ad64e7ed8 100644 --- a/apps/mobile/src/components/home/home-screen.test.ts +++ b/apps/mobile/src/components/home/home-screen.test.ts @@ -47,6 +47,9 @@ 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, })); diff --git a/apps/mobile/src/lib/code-reviewer-status.ts b/apps/mobile/src/lib/code-reviewer-status.ts index 44ff763c33..6890642a9e 100644 --- a/apps/mobile/src/lib/code-reviewer-status.ts +++ b/apps/mobile/src/lib/code-reviewer-status.ts @@ -11,7 +11,7 @@ import { canManageOrganizationBilling } from '@kilocode/app-shared/organizations // 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. -export type ProviderState = +type ProviderState = | { status: 'loading' } | { status: 'error'; refetch: () => void; isRetrying: boolean } | { status: 'connected' } diff --git a/apps/mobile/src/lib/hooks/use-code-reviewer.ts b/apps/mobile/src/lib/hooks/use-code-reviewer.ts index 45099c555a..6bb74ba138 100644 --- a/apps/mobile/src/lib/hooks/use-code-reviewer.ts +++ b/apps/mobile/src/lib/hooks/use-code-reviewer.ts @@ -299,15 +299,8 @@ export function useSaveReviewConfig(scope: string, platform: ReviewerPlatform) { // 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 { - classifyPermission, - classifyProviderState, - type PermissionState, - type ProviderState, - useCanEditReviewer, - useReviewerEditGuard, - useReviewerPermission, -} from '@/lib/hooks/use-reviewer-permission'; +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-reviewer-permission.ts b/apps/mobile/src/lib/hooks/use-reviewer-permission.ts index 10bc8ff930..616269b422 100644 --- a/apps/mobile/src/lib/hooks/use-reviewer-permission.ts +++ b/apps/mobile/src/lib/hooks/use-reviewer-permission.ts @@ -6,13 +6,6 @@ import { classifyPermission, type PermissionState } from '@/lib/code-reviewer-st import { PERSONAL_SCOPE, type ReviewerPlatform } from '@/lib/code-reviewer-config'; import { useTRPC } from '@/lib/trpc'; -export { - classifyPermission, - classifyProviderState, - type PermissionState, - type ProviderState, -} from '@/lib/code-reviewer-status'; - function isPersonal(scope: string) { return scope === PERSONAL_SCOPE; } @@ -34,11 +27,6 @@ export function useReviewerPermission(scope: string): PermissionState { }); } -export function useCanEditReviewer(scope: string) { - const permission = useReviewerPermission(scope); - return permission.status === 'ready' && permission.canEdit; -} - /** * Nested code-reviewer settings routes (instructions/repos/focus-areas/ * style/gate) are directly reachable by URL regardless of whether the From 7e2e93b7a6933c2f1c5a9c680db8465f5059b058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 15:40:00 +0200 Subject: [PATCH 116/166] fix(mobile): consistent disabled control presentation --- apps/mobile/src/app/(app)/agent-chat/new.tsx | 17 +++++++++++--- .../src/components/agents/chat-composer.tsx | 22 +++++++++++++++---- .../src/components/agents/chat-toolbar.tsx | 1 + .../src/components/agents/mode-selector.tsx | 3 +++ .../src/components/agents/model-selector.tsx | 2 ++ .../src/components/agents/question-card.tsx | 1 + .../src/components/agents/repo-selector.tsx | 3 +++ .../src/components/agents/tool-card-shell.tsx | 8 ++++++- .../code-reviewer/manual-review-screen.tsx | 1 + .../kilo-chat/message-attachment.tsx | 1 + .../kilo-chat/message-image-preview-modal.tsx | 1 + .../kilo-chat/message-input-view.tsx | 4 +++- .../components/kiloclaw/dashboard-parts.tsx | 6 +++++ .../kiloclaw/onboarding/identity-step.tsx | 1 + .../components/kiloclaw/version-pin-row.tsx | 1 + apps/mobile/src/components/rename-modal.tsx | 7 +++++- .../security-agent/sla-settings-screen.tsx | 1 + 17 files changed, 70 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/app/(app)/agent-chat/new.tsx b/apps/mobile/src/app/(app)/agent-chat/new.tsx index c06d81c152..21af2ee682 100644 --- a/apps/mobile/src/app/(app)/agent-chat/new.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/new.tsx @@ -47,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; @@ -285,6 +286,8 @@ export default function NewSessionScreen() { Boolean(model) && !attachments.isUploading && !attachments.hasFailedAttachments; + const paperclipDisabled = + isCreating || attachments.attachments.length >= AGENT_ATTACHMENT_MAX_FILES; return ( @@ -311,11 +314,15 @@ export default function NewSessionScreen() { onPress={() => { 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 }} > @@ -324,7 +331,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 }, @@ -341,6 +351,7 @@ export default function NewSessionScreen() { onLayout={handlePromptInputLayout} scrollEnabled={promptMeasure.height >= PROMPT_INPUT_MAX_HEIGHT} editable={!isCreating} + accessibilityState={{ disabled: isCreating }} autoFocus /> diff --git a/apps/mobile/src/components/agents/chat-composer.tsx b/apps/mobile/src/components/agents/chat-composer.tsx index b894bae27c..c1b910d141 100644 --- a/apps/mobile/src/components/agents/chat-composer.tsx +++ b/apps/mobile/src/components/agents/chat-composer.tsx @@ -27,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; @@ -93,6 +94,8 @@ export function ChatComposer({ const canSend = hasText && !disabled && !isStreaming && !isSending; const showToolbar = isFocused || hasText || upload.attachments.length > 0; const toolbarDisabled = disabled || isStreaming; + const paperclipDisabled = + toolbarDisabled || upload.attachments.length >= AGENT_ATTACHMENT_MAX_FILES; function handleChangeText(value: string) { textRef.current = value; @@ -199,18 +202,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" @@ -244,7 +255,10 @@ 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' + )} > diff --git a/apps/mobile/src/components/agents/chat-toolbar.tsx b/apps/mobile/src/components/agents/chat-toolbar.tsx index 9251c8cb13..977e4e4bb3 100644 --- a/apps/mobile/src/components/agents/chat-toolbar.tsx +++ b/apps/mobile/src/components/agents/chat-toolbar.tsx @@ -72,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/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 {option.name} diff --git a/apps/mobile/src/components/agents/question-card.tsx b/apps/mobile/src/components/agents/question-card.tsx index 0cfd117460..793ea4e9f7 100644 --- a/apps/mobile/src/components/agents/question-card.tsx +++ b/apps/mobile/src/components/agents/question-card.tsx @@ -177,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 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({ {status === 'pending' || status === 'running' ? ( 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 fd959eb72b..94bc274b70 100644 --- a/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx @@ -154,6 +154,7 @@ export function ManualReviewScreen({ scope }: Readonly<{ scope: string }>) { {isRetrying ? ( @@ -201,6 +204,9 @@ export function DangerZone({ pending, onDestroy }: Readonly) { diff --git a/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx b/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx index 6bdf31d0d9..f4d224c40b 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx @@ -413,6 +413,7 @@ export function IdentityStep({ void handleGpsPress(); }} disabled={isGpsLoading || isValidating} + accessibilityState={{ disabled: isValidating, busy: isGpsLoading }} className="h-11 w-11 items-center justify-center rounded-xl bg-secondary active:opacity-70 disabled:opacity-50" > {isGpsLoading ? ( diff --git a/apps/mobile/src/components/kiloclaw/version-pin-row.tsx b/apps/mobile/src/components/kiloclaw/version-pin-row.tsx index 148a63139d..0ffc57520e 100644 --- a/apps/mobile/src/components/kiloclaw/version-pin-row.tsx +++ b/apps/mobile/src/components/kiloclaw/version-pin-row.tsx @@ -93,6 +93,7 @@ export function VersionPinRow({ multiline maxLength={500} editable={!isConfirmingThis} + accessibilityState={{ busy: isConfirmingThis }} /> {isPinnedByAdmin && adminPinLabel && ( diff --git a/apps/mobile/src/components/rename-modal.tsx b/apps/mobile/src/components/rename-modal.tsx index df537fd0a4..510bf7cf28 100644 --- a/apps/mobile/src/components/rename-modal.tsx +++ b/apps/mobile/src/components/rename-modal.tsx @@ -4,6 +4,7 @@ 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; @@ -77,7 +78,10 @@ export function RenameModal({ {title} {errorText ? {errorText} : null} 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 00a93a8a59..4cf84ba062 100644 --- a/apps/mobile/src/components/security-agent/sla-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/sla-settings-screen.tsx @@ -101,6 +101,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} From d8b0eac8fd395e7ab1b14093fd4375a7d6a7068f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 15:41:18 +0200 Subject: [PATCH 117/166] fix(mobile): pressed feedback on inert-feeling controls --- .../src/components/agents/platform-filter-modal.tsx | 6 ++++-- .../src/components/agents/reasoning-part-renderer.tsx | 6 +++++- apps/mobile/src/components/agents/session-row.tsx | 11 ++++++++++- apps/mobile/src/components/home/section-header.tsx | 8 +++++++- .../components/kilo-chat/message-reaction-pills.tsx | 5 ++++- 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/components/agents/platform-filter-modal.tsx b/apps/mobile/src/components/agents/platform-filter-modal.tsx index 007195c259..a1b20155db 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`} > 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/session-row.tsx b/apps/mobile/src/components/agents/session-row.tsx index 851d7208a8..ff21b68c76 100644 --- a/apps/mobile/src/components/agents/session-row.tsx +++ b/apps/mobile/src/components/agents/session-row.tsx @@ -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/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/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' )} > From b28c4f38e4e8c875cd2de638cf3491b8de44279b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 15:43:36 +0200 Subject: [PATCH 118/166] fix(mobile): assorted consistency nits --- apps/mobile/src/components/home/greeting.tsx | 4 ++-- apps/mobile/src/components/home/home-screen.tsx | 2 +- .../mobile/src/components/kiloclaw/access-required-screen.tsx | 3 +++ .../src/components/security-agent/security-agent-setup.tsx | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) 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.tsx b/apps/mobile/src/components/home/home-screen.tsx index c5f48a0269..d35fddd994 100644 --- a/apps/mobile/src/components/home/home-screen.tsx +++ b/apps/mobile/src/components/home/home-screen.tsx @@ -99,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 () => { diff --git a/apps/mobile/src/components/kiloclaw/access-required-screen.tsx b/apps/mobile/src/components/kiloclaw/access-required-screen.tsx index 733ce5d704..0bbeeb18fe 100644 --- a/apps/mobile/src/components/kiloclaw/access-required-screen.tsx +++ b/apps/mobile/src/components/kiloclaw/access-required-screen.tsx @@ -132,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/security-agent/security-agent-setup.tsx b/apps/mobile/src/components/security-agent/security-agent-setup.tsx index 4daa6e17d5..9fc9e80b49 100644 --- a/apps/mobile/src/components/security-agent/security-agent-setup.tsx +++ b/apps/mobile/src/components/security-agent/security-agent-setup.tsx @@ -51,7 +51,7 @@ export function SecurityAgentSetup({ void connect(); }} > - {connecting ? : null} + {connecting ? : null} {buttonLabel} From ff18029abe82e13ede59f9978d004ea90bdb276b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 15:45:01 +0200 Subject: [PATCH 119/166] fix(mobile): KVRow overflow handling --- apps/mobile/src/components/ui/kv-row.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/components/ui/kv-row.tsx b/apps/mobile/src/components/ui/kv-row.tsx index 5aaae30a98..4aec85f1ad 100644 --- a/apps/mobile/src/components/ui/kv-row.tsx +++ b/apps/mobile/src/components/ui/kv-row.tsx @@ -56,12 +56,12 @@ export function KvRow({ return ( - + {dotTone ? : null} {Icon ? : null} {label} @@ -69,7 +69,9 @@ export function KvRow({ {value} From edb90b469694b95c123e0d9efdce8946f2ce70a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 15:46:48 +0200 Subject: [PATCH 120/166] fix(mobile): consistent empty and error state placement --- .../app/(app)/(tabs)/(1_kiloclaw)/index.tsx | 6 +- .../src/app/(app)/agent-chat/repo-picker.tsx | 17 ++-- .../kiloclaw/[instance-id]/changelog.tsx | 82 +++++++++++-------- .../[instance-id]/settings/channels.tsx | 82 ++++++++++--------- .../[instance-id]/settings/secrets.tsx | 82 ++++++++++--------- .../agents/model-picker-content.tsx | 15 ++-- .../agents/session-detail-content.tsx | 8 +- .../agents/session-list-content.tsx | 6 +- .../code-reviewer/bitbucket-overview.tsx | 31 ++++--- .../code-reviewer/platform-error-screen.tsx | 21 +++++ .../platform-overview-screen.tsx | 30 +++---- .../kilo-chat/conversation-list-screen.tsx | 6 +- .../organization/credit-activity-screen.tsx | 3 +- .../organization/invoices-screen.tsx | 3 +- .../analysis-settings-screen.tsx | 14 ++-- .../automation-settings-screen.tsx | 14 ++-- .../security-agent/dashboard-sections.tsx | 13 ++- .../security-agent/finding-detail-screen.tsx | 19 +++-- .../notification-settings-screen.tsx | 14 ++-- .../repository-settings-screen.tsx | 14 ++-- .../security-agent/scope-entry-screen.tsx | 16 ++-- .../security-agent/scope-list-screen.tsx | 17 ++-- .../settings-overview-screen.tsx | 14 ++-- .../security-agent/sla-settings-screen.tsx | 11 ++- 24 files changed, 313 insertions(+), 225 deletions(-) create mode 100644 apps/mobile/src/components/code-reviewer/platform-error-screen.tsx 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 227b85c957..68ccedbf60 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx @@ -66,7 +66,11 @@ export default function KiloClawTab() { return ( - + } ListEmptyComponent={ - - - {search.trim() ? 'No repositories match your search' : 'No repositories available'} - - + } renderItem={({ item: repo }) => ( + if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { + return ( + + + + + ); + } + + if (changelogQuery.isPending) { + return ( + + + - ); - } - if (changelogQuery.isError) { - return ( - { - void changelogQuery.refetch(); - }} - /> - ); - } - if (!entries || entries.length === 0) { - return ( - - ); - } + + ); + } + + if (changelogQuery.isError) { return ( - - - + + + + { + void changelogQuery.refetch(); + }} + /> + + ); } - if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { + if (!entries || entries.length === 0) { return ( - + + + ); } @@ -103,7 +106,14 @@ export default function ChangelogScreen() { Recent Updates - {renderContent()} + + + 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 ba4dbe2447..47c14a0239 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 @@ -24,19 +24,33 @@ export default function ChannelsScreen() { const isLoading = catalogQuery.isPending; - function renderContent() { - if (isLoading) { - return ( - + if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { + return ( + + + + + ); + } + + if (isLoading) { + return ( + + + - ); - } - if (catalogQuery.isError) { - return ( - + + ); + } + + if (catalogQuery.isError) { + return ( + + + { @@ -44,40 +58,21 @@ export default function ChannelsScreen() { }} /> - ); - } - if (catalogQuery.data.length === 0) { - return ( - - - - ); - } - return ( - - {catalogQuery.data.map(channel => ( - - ))} - + ); } - if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { + if (catalogQuery.data.length === 0) { return ( - + + + ); } @@ -94,7 +89,18 @@ export default function ChannelsScreen() { keyboardDismissMode="interactive" keyboardShouldPersistTaps="handled" > - {renderContent()} + + {catalogQuery.data.map(channel => ( + + ))} + 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 cb9d7e6580..dedfbaa5d1 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 @@ -23,20 +23,34 @@ export default function SecretsScreen() { const paddingBottom = useDetailScreenBottomPadding(); const isLoading = catalogQuery.isPending; - function renderContent() { - if (isLoading) { - return ( - + if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { + return ( + + + + + ); + } + + if (isLoading) { + return ( + + + - ); - } - if (catalogQuery.isError) { - return ( - + + ); + } + + if (catalogQuery.isError) { + return ( + + + { @@ -44,40 +58,21 @@ export default function SecretsScreen() { }} /> - ); - } - if (catalogQuery.data.length === 0) { - return ( - - - - ); - } - return ( - - {catalogQuery.data.map(secret => ( - - ))} - + ); } - if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { + if (catalogQuery.data.length === 0) { return ( - + + + ); } @@ -94,7 +89,18 @@ export default function SecretsScreen() { keyboardDismissMode="interactive" keyboardShouldPersistTaps="handled" > - {renderContent()} + + {catalogQuery.data.map(secret => ( + + ))} + diff --git a/apps/mobile/src/components/agents/model-picker-content.tsx b/apps/mobile/src/components/agents/model-picker-content.tsx index 25f6dea1bc..fdb7e56afe 100644 --- a/apps/mobile/src/components/agents/model-picker-content.tsx +++ b/apps/mobile/src/components/agents/model-picker-content.tsx @@ -1,6 +1,6 @@ import * as Haptics from 'expo-haptics'; import { useFocusEffect, useRouter } from 'expo-router'; -import { AlertCircle, Info, Search } from 'lucide-react-native'; +import { AlertCircle, Info, Search, SearchX } from 'lucide-react-native'; import { useCallback, useMemo, useRef, useState } from 'react'; import { FlatList, TextInput, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -184,11 +184,14 @@ export function ModelPickerContent() { } ListEmptyComponent={ - - - {search.trim() ? 'No models match your search' : 'No models available'} - - + } renderItem={({ item }) => { if (item.type === 'header') { diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 6b11a34dde..a992805755 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -2,6 +2,7 @@ import { type CloudStatus, type KiloSessionId, type StoredMessage } from 'cloud-agent-sdk'; import { type Href, useRouter } from 'expo-router'; import { useAtomValue } from 'jotai'; +import { MessageSquare } from 'lucide-react-native'; import { useCallback, useEffect, useMemo, useRef } from 'react'; import { FlatList, KeyboardAvoidingView, Platform, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -20,6 +21,7 @@ import { shouldShowAgentWorkingIndicator, shouldShowFooterWorkingIndicator, } from '@/components/agents/session-working-state'; +import { EmptyState } from '@/components/empty-state'; import { AppAwareKeyboardPaddingView } from '@/components/kilo-chat/app-aware-keyboard-padding'; import { useInteractionHandlers } from '@/components/agents/use-interaction-handlers'; import { useSessionAutoScroll } from '@/components/agents/use-session-auto-scroll'; @@ -476,7 +478,11 @@ export function SessionDetailContent({ {statusIndicator ? : null} {emptyStateText ? ( - {emptyStateText} + ) : null} ); diff --git a/apps/mobile/src/components/agents/session-list-content.tsx b/apps/mobile/src/components/agents/session-list-content.tsx index c228cc1ca9..703bf1b563 100644 --- a/apps/mobile/src/components/agents/session-list-content.tsx +++ b/apps/mobile/src/components/agents/session-list-content.tsx @@ -241,7 +241,11 @@ export function AgentSessionListContent({ // cache (keepPreviousData) must never blank out what's already rendered. if (isError && !hasAnySessions) { return ( - + ); diff --git a/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx b/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx index 13f5e6c170..abd4ba285b 100644 --- a/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx +++ b/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx @@ -18,7 +18,7 @@ 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 { 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'; @@ -49,6 +49,7 @@ export function BitbucketOverview({ permissionLoading: boolean; }>) { const router = useRouter(); + const paddingBottom = useTabBarBottomPadding(); const readiness = useBitbucketReadiness(scope); const save = useSaveReviewConfig(scope, 'bitbucket'); const { models, isLoading: modelsLoading } = useAvailableModels(scope); @@ -65,12 +66,14 @@ export function BitbucketOverview({ return ( - { - providerState.refetch(); - }} - isRetrying={providerState.isRetrying} - /> + + { + providerState.refetch(); + }} + isRetrying={providerState.isRetrying} + /> + ); } @@ -86,12 +89,14 @@ export function BitbucketOverview({ return ( - { - void config.refetch(); - }} - isRetrying={config.isFetching} - /> + + { + void config.refetch(); + }} + isRetrying={config.isFetching} + /> + ); } diff --git a/apps/mobile/src/components/code-reviewer/platform-error-screen.tsx b/apps/mobile/src/components/code-reviewer/platform-error-screen.tsx new file mode 100644 index 0000000000..e25f909f85 --- /dev/null +++ b/apps/mobile/src/components/code-reviewer/platform-error-screen.tsx @@ -0,0 +1,21 @@ +import { View } from 'react-native'; + +import { QueryError } from '@/components/query-error'; +import { ScreenHeader } from '@/components/screen-header'; +import { useTabBarBottomPadding } from '@/components/tab-screen'; + +export function PlatformErrorScreen({ + title, + onRetry, + isRetrying, +}: Readonly<{ title: string; onRetry: () => void; isRetrying: boolean }>) { + const paddingBottom = useTabBarBottomPadding(); + return ( + + + + + + + ); +} 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 5a1992da10..b9ae5e6061 100644 --- a/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx @@ -1,19 +1,21 @@ import * as Haptics from 'expo-haptics'; import { type Href, useRouter } from 'expo-router'; +import { Building2 } from 'lucide-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 { PlatformErrorScreen } from '@/components/code-reviewer/platform-error-screen'; import { buildOverviewRows } from '@/components/code-reviewer/platform-overview-rows'; import { ProviderConnectCard } from '@/components/code-reviewer/provider-connect-card'; -import { QueryError } from '@/components/query-error'; +import { EmptyState } from '@/components/empty-state'; 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 { TabScreenScrollView, useTabBarBottomPadding } from '@/components/tab-screen'; import { PLATFORM_CAPABILITIES, type ReviewerPlatform } from '@/lib/code-reviewer-config'; import { useAvailableModels } from '@/lib/hooks/use-available-models'; import { @@ -29,25 +31,13 @@ import { } from '@/lib/hooks/use-code-reviewer'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -function PlatformErrorScreen({ - title, - onRetry, - isRetrying, -}: Readonly<{ title: string; onRetry: () => void; isRetrying: boolean }>) { - return ( - - - - - ); -} - export function PlatformOverviewScreen({ scope, platform, }: Readonly<{ scope: string; platform: ReviewerPlatform }>) { const router = useRouter(); const colors = useThemeColors(); + const paddingBottom = useTabBarBottomPadding(); const capabilities = PLATFORM_CAPABILITIES[platform]; const githubStatus = useGitHubStatus(scope); const gitlabStatus = useGitLabStatus(scope); @@ -65,10 +55,12 @@ export function PlatformOverviewScreen({ return ( - - - Bitbucket is available for organizations only. - + + ); 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 f2626014cf..1f5e4781fe 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx @@ -195,7 +195,11 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { return ( - + item.id} renderItem={({ item }) => } - contentContainerClassName="gap-3 px-6 pt-4" + contentContainerClassName="grow gap-3 px-6 pt-4" contentContainerStyle={{ paddingBottom }} ListEmptyComponent={ diff --git a/apps/mobile/src/components/organization/invoices-screen.tsx b/apps/mobile/src/components/organization/invoices-screen.tsx index c8d0a79c6c..93d8a97e26 100644 --- a/apps/mobile/src/components/organization/invoices-screen.tsx +++ b/apps/mobile/src/components/organization/invoices-screen.tsx @@ -124,12 +124,11 @@ export function OrganizationInvoicesScreen() { data={invoices} keyExtractor={item => item.id} renderItem={({ item }) => } - contentContainerClassName="gap-3 px-6 pt-4" + contentContainerClassName="grow gap-3 px-6 pt-4" contentContainerStyle={{ paddingBottom }} ListEmptyComponent={ 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 973a9952f2..3ae6f26cad 100644 --- a/apps/mobile/src/components/security-agent/analysis-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/analysis-settings-screen.tsx @@ -15,7 +15,7 @@ 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 { TabScreenScrollView, useTabBarBottomPadding } from '@/components/tab-screen'; import { useAvailableModels } from '@/lib/hooks/use-available-models'; import { useSecurityAgentSettingsRedirect, @@ -53,6 +53,7 @@ function AnalysisSettingsSkeleton() { export function AnalysisSettingsScreen({ scope }: Readonly<{ scope: string }>) { const router = useRouter(); + const paddingBottom = useTabBarBottomPadding(); const canManage = useSecurityAgentEditCapability(scope); const config = useSecurityAgentConfig(scope); const save = useSaveSecurityAgentConfig(scope); @@ -111,11 +112,12 @@ export function AnalysisSettingsScreen({ scope }: Readonly<{ scope: string }>) { return ( - void config.refetch()} - /> + + void config.refetch()} + /> + ); } 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 fafd3ba98e..c06dbd6ace 100644 --- a/apps/mobile/src/components/security-agent/automation-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/automation-settings-screen.tsx @@ -13,7 +13,7 @@ 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 { TabScreenScrollView, useTabBarBottomPadding } from '@/components/tab-screen'; import { useSecurityAgentSettingsRedirect, useSettingsBackGuard, @@ -58,6 +58,7 @@ function AutomationSettingsSkeleton() { } export function AutomationSettingsScreen({ scope }: Readonly<{ scope: string }>) { + const paddingBottom = useTabBarBottomPadding(); const canManage = useSecurityAgentEditCapability(scope); const config = useSecurityAgentConfig(scope); const save = useSaveSecurityAgentConfig(scope); @@ -143,11 +144,12 @@ export function AutomationSettingsScreen({ scope }: Readonly<{ scope: string }>) return ( - void config.refetch()} - /> + + void config.refetch()} + /> + ); } 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/finding-detail-screen.tsx b/apps/mobile/src/components/security-agent/finding-detail-screen.tsx index 101f1f9604..12fc8fe389 100644 --- a/apps/mobile/src/components/security-agent/finding-detail-screen.tsx +++ b/apps/mobile/src/components/security-agent/finding-detail-screen.tsx @@ -12,7 +12,7 @@ import { FindingDetailsPanel } from '@/components/security-agent/finding-details import { FindingRemediationPanel } from '@/components/security-agent/finding-remediation-panel'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; -import { TabScreenScrollView } from '@/components/tab-screen'; +import { TabScreenScrollView, useTabBarBottomPadding } from '@/components/tab-screen'; import { useSecurityAgentOrgRole, useTrackSecurityAgentInteraction, @@ -49,6 +49,7 @@ type FindingDetailScreenProps = { export function FindingDetailScreen({ scope, findingId }: Readonly) { const router = useRouter(); const colors = useThemeColors(); + const paddingBottom = useTabBarBottomPadding(); const [tab, setTab] = useState('details'); const findingQuery = useSecurityFinding(scope, findingId); const analysisQuery = useSecurityAnalysis(scope, findingId); @@ -86,12 +87,14 @@ export function FindingDetailScreen({ scope, findingId }: Readonly - + + + ); } @@ -100,7 +103,7 @@ export function FindingDetailScreen({ scope, findingId }: Readonly - + void findingQuery.refetch()} diff --git a/apps/mobile/src/components/security-agent/notification-settings-screen.tsx b/apps/mobile/src/components/security-agent/notification-settings-screen.tsx index bce810e06a..5bdb5cb850 100644 --- a/apps/mobile/src/components/security-agent/notification-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/notification-settings-screen.tsx @@ -15,7 +15,7 @@ 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 { TabScreenScrollView, useTabBarBottomPadding } from '@/components/tab-screen'; import { useSecurityAgentSettingsRedirect, useSettingsBackGuard, @@ -58,6 +58,7 @@ function NotificationSettingsSkeleton() { export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string }>) { const colors = useThemeColors(); + const paddingBottom = useTabBarBottomPadding(); const canManage = useSecurityAgentEditCapability(scope); const config = useSecurityAgentConfig(scope); const save = useSaveSecurityAgentConfig(scope); @@ -144,11 +145,12 @@ export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string } return ( - void config.refetch()} - /> + + void config.refetch()} + /> + ); } diff --git a/apps/mobile/src/components/security-agent/repository-settings-screen.tsx b/apps/mobile/src/components/security-agent/repository-settings-screen.tsx index e81f94d91f..e1b638ba79 100644 --- a/apps/mobile/src/components/security-agent/repository-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/repository-settings-screen.tsx @@ -15,7 +15,7 @@ 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 { TabScreenScrollView } from '@/components/tab-screen'; +import { TabScreenScrollView, useTabBarBottomPadding } from '@/components/tab-screen'; import { getGitHubIntegrationUrl } from '@/lib/agent-github-integration'; import { WEB_BASE_URL } from '@/lib/config'; import { openExternalUrl } from '@/lib/external-link'; @@ -51,6 +51,7 @@ function RepositorySettingsSkeleton() { export function RepositorySettingsScreen({ scope }: Readonly<{ scope: string }>) { const colors = useThemeColors(); + const paddingBottom = useTabBarBottomPadding(); const canManage = useSecurityAgentEditCapability(scope); const config = useSecurityAgentConfig(scope); const repositories = useSecurityAgentRepositories(scope); @@ -93,11 +94,12 @@ export function RepositorySettingsScreen({ scope }: Readonly<{ scope: string }>) return ( - void config.refetch()} - /> + + void config.refetch()} + /> + ); } 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 f6817874ce..1b46167623 100644 --- a/apps/mobile/src/components/security-agent/scope-entry-screen.tsx +++ b/apps/mobile/src/components/security-agent/scope-entry-screen.tsx @@ -11,6 +11,7 @@ import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; import { DashboardScreen } from '@/components/security-agent/dashboard-screen'; import { SecurityAgentSetup } from '@/components/security-agent/security-agent-setup'; +import { useTabBarBottomPadding } from '@/components/tab-screen'; import { Skeleton } from '@/components/ui/skeleton'; import { getGitHubIntegrationUrl } from '@/lib/agent-github-integration'; import { WEB_BASE_URL } from '@/lib/config'; @@ -47,15 +48,18 @@ function ScopeEntryError({ onRetry, isRetrying, }: Readonly<{ onRetry: () => void; isRetrying: boolean }>) { + const paddingBottom = useTabBarBottomPadding(); return ( - + + + ); } diff --git a/apps/mobile/src/components/security-agent/scope-list-screen.tsx b/apps/mobile/src/components/security-agent/scope-list-screen.tsx index 7625944fc9..06282e4431 100644 --- a/apps/mobile/src/components/security-agent/scope-list-screen.tsx +++ b/apps/mobile/src/components/security-agent/scope-list-screen.tsx @@ -8,7 +8,7 @@ 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 { TabScreenScrollView, useTabBarBottomPadding } from '@/components/tab-screen'; import { getSecurityAgentPath } from '@/lib/security-agent'; import { useTRPC } from '@/lib/trpc'; @@ -16,6 +16,7 @@ import { useTRPC } from '@/lib/trpc'; // org members to operate Security Agent, so members are not labeled "View only". export function ScopeListScreen() { const router = useRouter(); + const paddingBottom = useTabBarBottomPadding(); const trpc = useTRPC(); const { data: orgs, @@ -33,12 +34,14 @@ export function ScopeListScreen() { return ( - void refetch()} - isRetrying={isFetching} - /> + + void refetch()} + isRetrying={isFetching} + /> + ); } 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 e03b450cad..de2e4f23e3 100644 --- a/apps/mobile/src/components/security-agent/settings-overview-screen.tsx +++ b/apps/mobile/src/components/security-agent/settings-overview-screen.tsx @@ -10,7 +10,7 @@ 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 { TabScreenScrollView, useTabBarBottomPadding } from '@/components/tab-screen'; import { WEB_BASE_URL } from '@/lib/config'; import { openExternalUrl } from '@/lib/external-link'; import { @@ -42,6 +42,7 @@ function capitalize(value: string) { export function SettingsOverviewScreen({ scope }: Readonly<{ scope: string }>) { const router = useRouter(); const colors = useThemeColors(); + const paddingBottom = useTabBarBottomPadding(); const config = useSecurityAgentConfig(scope); const canManage = useSecurityAgentEditCapability(scope); const setEnabled = useSetSecurityAgentEnabled(scope); @@ -66,11 +67,12 @@ export function SettingsOverviewScreen({ scope }: Readonly<{ scope: string }>) { return ( - void config.refetch()} - /> + + void config.refetch()} + /> + ); } 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 4cf84ba062..da70415948 100644 --- a/apps/mobile/src/components/security-agent/sla-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/sla-settings-screen.tsx @@ -13,7 +13,7 @@ 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 { TabScreenScrollView, useTabBarBottomPadding } from '@/components/tab-screen'; import { useSecurityAgentSettingsRedirect, useSettingsBackGuard, @@ -117,6 +117,7 @@ function SlaDayRow({ } export function SlaSettingsScreen({ scope }: Readonly<{ scope: string }>) { + const paddingBottom = useTabBarBottomPadding(); const canManage = useSecurityAgentEditCapability(scope); const config = useSecurityAgentConfig(scope); const save = useSaveSecurityAgentConfig(scope); @@ -203,11 +204,9 @@ export function SlaSettingsScreen({ scope }: Readonly<{ scope: string }>) { return ( - void config.refetch()} - /> + + void config.refetch()} /> + ); } From 2218e9c1d36e4db92efcef90cb58f11b182c5ec5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 15:52:28 +0200 Subject: [PATCH 121/166] fix(mobile): honest pull-to-refresh everywhere --- apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx | 6 +++++- .../src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx | 6 +++++- .../src/components/agents/session-list-content.tsx | 9 +++++---- .../components/kilo-chat/conversation-list-screen.tsx | 6 +++++- .../components/security-agent/finding-list-screen.tsx | 6 +++++- apps/mobile/src/lib/hooks/use-agent-sessions.ts | 6 +++++- 6 files changed, 30 insertions(+), 9 deletions(-) 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 68ccedbf60..97579294ad 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx @@ -3,6 +3,7 @@ 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 { toast } from 'sonner-native'; import { EmptyStateContent, @@ -49,7 +50,10 @@ export default function KiloClawTab() { void (async () => { setManualRefreshing(true); try { - await refetchInstances(); + const result = await refetchInstances(); + if (result.isError) { + toast.error('Could not refresh — showing the last saved list.'); + } } finally { setManualRefreshing(false); } 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 11b5f3fe7a..661f1e3c7b 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx @@ -11,6 +11,7 @@ import { View, } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; +import { toast } from 'sonner-native'; import { BillingBanner } from '@/components/kiloclaw/billing-banner'; import { @@ -89,7 +90,10 @@ export default function DashboardScreen() { ...(isRunning ? [refetchGateway()] : []), ...(isPersonal ? [refetchBilling()] : []), ]; - await Promise.all(refreshes); + const results = await Promise.all(refreshes); + if (results.some(result => result.isError)) { + toast.error('Could not refresh — showing the last known status.'); + } } finally { setManualRefreshing(false); } diff --git a/apps/mobile/src/components/agents/session-list-content.tsx b/apps/mobile/src/components/agents/session-list-content.tsx index 703bf1b563..66f59fbe2f 100644 --- a/apps/mobile/src/components/agents/session-list-content.tsx +++ b/apps/mobile/src/components/agents/session-list-content.tsx @@ -36,7 +36,7 @@ type AgentSessionListContentProps = { isSearchPending: boolean; isError: boolean; isFetchingNextPage: boolean; - refetch: () => Promise; + refetch: () => Promise; onRetry: () => void; onEndReached: () => void; onSessionPress: (sessionId: string, organizationId?: string | null) => void; @@ -167,9 +167,10 @@ export function AgentSessionListContent({ void (async () => { setRefreshing(true); try { - await refetch(); - } catch { - toast.error('Could not refresh sessions — showing the last saved list.'); + const hadError = await refetch(); + if (hadError) { + toast.error('Could not refresh sessions — showing the last saved list.'); + } } finally { setRefreshing(false); } 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 1f5e4781fe..bc9b4d56f2 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx @@ -14,6 +14,7 @@ import { } from 'react-native'; import Animated, { FadeIn } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { toast } from 'sonner-native'; import { QueryError } from '@/components/query-error'; import { captureEvent, CONVERSATION_CREATED_EVENT } from '@/lib/analytics/posthog'; @@ -167,7 +168,10 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { void (async () => { setManualRefreshing(true); try { - await refetchConversations(); + const result = await refetchConversations(); + if (result.isError) { + toast.error('Could not refresh — showing the last saved list.'); + } } finally { setManualRefreshing(false); } 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 82c2a5aef1..ec878c7361 100644 --- a/apps/mobile/src/components/security-agent/finding-list-screen.tsx +++ b/apps/mobile/src/components/security-agent/finding-list-screen.tsx @@ -10,6 +10,7 @@ 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'; @@ -79,7 +80,10 @@ export function FindingListScreen({ scope, routeParams }: Readonly { - await Promise.all([stored.refetch(), active.refetch()]); + const [storedResult, activeResult] = await Promise.all([stored.refetch(), active.refetch()]); + return storedResult.isError || activeResult.isError; }, }; } From 305f72610e4e2dc045135d600a850cf4759d0a6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 15:57:31 +0200 Subject: [PATCH 122/166] refactor(mobile): shared formatDate in app-shared --- .../organization/credit-activity-screen.tsx | 6 +- .../organization/invited-member-row.tsx | 4 +- .../organization/invoices-screen.tsx | 4 +- .../src/components/profile-credits-card.tsx | 7 +- .../src/lib/hooks/use-kiloclaw-billing.ts | 8 +-- .../lib/kilo-pass/subscription-card-state.ts | 8 +-- apps/mobile/src/lib/utils.ts | 4 +- packages/app-shared/src/utils.test.ts | 66 +++++++++++++++++++ packages/app-shared/src/utils.ts | 10 +++ 9 files changed, 91 insertions(+), 26 deletions(-) create mode 100644 packages/app-shared/src/utils.test.ts diff --git a/apps/mobile/src/components/organization/credit-activity-screen.tsx b/apps/mobile/src/components/organization/credit-activity-screen.tsx index b41f145e9b..9d9ef4b67a 100644 --- a/apps/mobile/src/components/organization/credit-activity-screen.tsx +++ b/apps/mobile/src/components/organization/credit-activity-screen.tsx @@ -16,7 +16,7 @@ import { useOrgBoundary, useOrgCreditTransactions, } from '@/lib/hooks/use-organization-queries'; -import { cn, firstNonEmpty, parseTimestamp } from '@/lib/utils'; +import { cn, firstNonEmpty, formatDate, parseTimestamp } from '@/lib/utils'; function humanizeCategory(category: string): string { const spaced = category.replaceAll('_', ' '); @@ -54,11 +54,11 @@ function CreditRow({ transaction }: Readonly<{ transaction: CreditTransaction }> - {parseTimestamp(transaction.created_at).toLocaleDateString()} + {formatDate(parseTimestamp(transaction.created_at))} {transaction.expiry_date != null && ( - Expires {parseTimestamp(transaction.expiry_date).toLocaleDateString()} + Expires {formatDate(parseTimestamp(transaction.expiry_date))} )} 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 93d8a97e26..4d43c10695 100644 --- a/apps/mobile/src/components/organization/invoices-screen.tsx +++ b/apps/mobile/src/components/organization/invoices-screen.tsx @@ -16,7 +16,7 @@ import { useOrgBoundary, useOrgInvoices, } from '@/lib/hooks/use-organization-queries'; -import { cn, firstNonEmpty } from '@/lib/utils'; +import { cn, firstNonEmpty, formatDate } from '@/lib/utils'; const STATUS_META: Record = { paid: { label: 'Paid', pillClass: 'bg-good', textClass: 'text-good-foreground' }, @@ -59,7 +59,7 @@ function InvoiceRow({ invoice }: Readonly<{ invoice: OrgInvoice }>) { - {new Date(invoice.created * 1000).toLocaleDateString()} + {formatDate(new Date(invoice.created * 1000))} {meta.label} diff --git a/apps/mobile/src/components/profile-credits-card.tsx b/apps/mobile/src/components/profile-credits-card.tsx index 2d68791405..a9dcfceb5f 100644 --- a/apps/mobile/src/components/profile-credits-card.tsx +++ b/apps/mobile/src/components/profile-credits-card.tsx @@ -15,7 +15,7 @@ import { openExternalUrl } from '@/lib/external-link'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; 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; @@ -152,10 +152,7 @@ export function CreditsCard({ enabled, orgs }: Readonly) { {formatDollars(expiringTotal)} in bonus credits expiring{' '} - {parseTimestamp(earliestExpiry).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - })} + {formatDate(parseTimestamp(earliestExpiry))} ) 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/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/utils.ts b/apps/mobile/src/lib/utils.ts index afafbc4d14..a4fddf2731 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,4 @@ 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 }; +export { asyncNoop, cn, EMAIL_PATTERN, firstNonEmpty, formatDate, parseTimestamp, timeAgo }; diff --git a/packages/app-shared/src/utils.test.ts b/packages/app-shared/src/utils.test.ts new file mode 100644 index 0000000000..dfb016d04e --- /dev/null +++ b/packages/app-shared/src/utils.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { + firstNonEmpty, + formatCents, + formatDate, + formatDollars, + fromMicrodollars, + 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' + ); + }); +}); + +describe('firstNonEmpty', () => { + it('returns the first non-empty value', () => { + expect(firstNonEmpty(undefined, null, '', 'a', 'b')).toBe('a'); + }); + + it('returns empty string when none are set', () => { + expect(firstNonEmpty(undefined, null, '')).toBe(''); + }); +}); + +describe('fromMicrodollars', () => { + it('divides by one million', () => { + expect(fromMicrodollars(1_500_000)).toBe(1.5); + }); +}); + +describe('formatDollars', () => { + it('formats as USD currency', () => { + expect(formatDollars(12.5)).toBe('$12.50'); + }); +}); + +describe('formatCents', () => { + it('formats cents as USD currency by default', () => { + expect(formatCents(1250)).toBe('$12.50'); + }); + + it('accepts an explicit currency code', () => { + expect(formatCents(1250, 'eur')).toBe('€12.50'); + }); +}); + +describe('formatDate', () => { + it('formats a Date as numeric month/day/year', () => { + // Local-time constructor avoids UTC/local rollover ambiguity across CI timezones. + expect(formatDate(new Date(2026, 6, 11))).toBe('7/11/2026'); + }); + + it('accepts a parsed backend timestamp', () => { + // Noon UTC keeps the same local calendar day across all realistic CI timezones. + expect(formatDate(parseTimestamp('2026-01-05 12:00:00+00'))).toBe('1/5/2026'); + }); +}); diff --git a/packages/app-shared/src/utils.ts b/packages/app-shared/src/utils.ts index bc9aaa318e..0fb0a901bc 100644 --- a/packages/app-shared/src/utils.ts +++ b/packages/app-shared/src/utils.ts @@ -44,3 +44,13 @@ export function formatCents(amount: number, currency: string = 'USD') { currency: currency.toUpperCase(), }).format(amount / 100); } + +/** + * Canonical app-wide date format (e.g. "7/11/2026"). + * + * Pass a `Date`, not a raw backend string — parse backend timestamps with + * `parseTimestamp()` first. + */ +export function formatDate(date: Date): string { + return date.toLocaleDateString('en-US'); +} From 008770e560e8e216c595bd21d4018545eac4f732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 16:00:38 +0200 Subject: [PATCH 123/166] fix(mobile): match skeletons to loaded content --- .../(1_kiloclaw)/chat/instance-picker.tsx | 21 +++--- .../app/(app)/(tabs)/(1_kiloclaw)/index.tsx | 8 +-- .../agents/session-list-content.tsx | 2 +- .../src/components/home/home-screen.tsx | 71 +++++++++++-------- .../conversation-history-state-views.tsx | 6 +- .../kilo-chat/conversation-list-screen.tsx | 9 ++- .../organization/low-balance-alert-sheet.tsx | 20 +++++- 7 files changed, 86 insertions(+), 51 deletions(-) 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 0373b70802..b17e69d499 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 @@ -2,6 +2,7 @@ import * as Haptics from 'expo-haptics'; 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'; @@ -78,11 +79,11 @@ export default function InstancePickerScreen() { }} > {instancesQuery.isPending ? ( - - - - - + + + + + ) : null} {instancesQuery.isError ? ( ) : null} - {showList - ? loadedInstances.map(instance => ( + {showList ? ( + + {loadedInstances.map(instance => ( - )) - : null} + ))} + + ) : 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 97579294ad..2b1d4a7dcb 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx @@ -118,10 +118,10 @@ export default function KiloClawTab() { {showInstanceSkeleton || onboardingQuery.data === undefined ? ( - - - - + + + + ) : ( {Array.from({ length: 8 }, (_, i) => ( - + ))} diff --git a/apps/mobile/src/components/home/home-screen.tsx b/apps/mobile/src/components/home/home-screen.tsx index d35fddd994..b9b59ce319 100644 --- a/apps/mobile/src/components/home/home-screen.tsx +++ b/apps/mobile/src/components/home/home-screen.tsx @@ -2,7 +2,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { useFocusEffect, useIsFocused } from 'expo-router'; import { useCallback, useEffect, useState } from 'react'; import { AppState, RefreshControl, View } from 'react-native'; -import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; +import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; import { TabScreenScrollView } from '@/components/tab-screen'; @@ -120,35 +120,48 @@ export function HomeScreen() { showsVerticalScrollIndicator={false} refreshControl={} > - {isLoading ? ( - - - - - ) : ( - - {renderKiloClawSlot({ - instances: instances ?? [], - instancesError, - handleRetryInstances: () => void refetchInstances(), - unreadByBadgeBucket, - })} - - {renderSessionsOrPromo({ - hasAnySession, - organizationId, - sessionsError: storedIsError, - sessionsLoadedEmpty: storedIsSuccess && !hasAnySession, - handleRetrySessions: () => void refetchSessions(), - })} - - {hasAnySession ? ( - - + + {isLoading ? ( + + + - ) : null} - - )} + + + + + + + + + + + + ) : ( + + {renderKiloClawSlot({ + instances: instances ?? [], + instancesError, + handleRetryInstances: () => void refetchInstances(), + unreadByBadgeBucket, + })} + + {renderSessionsOrPromo({ + hasAnySession, + organizationId, + sessionsError: storedIsError, + sessionsLoadedEmpty: storedIsSuccess && !hasAnySession, + handleRetrySessions: () => void refetchSessions(), + })} + + {hasAnySession ? ( + + + + ) : null} + + )} + ); 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 ab003a4a8c..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 @@ -17,9 +17,9 @@ export function ConversationHistoryLoadingView({ subtitle, title }: Props) { - - - + + + 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 bc9b4d56f2..c279a90701 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx @@ -61,8 +61,12 @@ const FAB_MARGIN = 16; function ConversationListSkeleton({ showHeader }: Readonly<{ showHeader?: boolean }>) { return ( - - {showHeader ? : null} + + {showHeader ? ( + + + + ) : null} {[0, 1, 2, 3].map(i => ( - ))} 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 8ff7e8fe6c..2a2d30309c 100644 --- a/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx +++ b/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx @@ -178,7 +178,15 @@ export function LowBalanceAlertSheet() { return ( - + + + + + + + + + ); } @@ -218,7 +226,15 @@ export function LowBalanceAlertSheet() { body = ( <> - + + + + + + + + + ); } From b9da944b461c7e34938b31fbd50fc29f20c25472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 16:09:23 +0200 Subject: [PATCH 124/166] refactor(mobile): status colors from Focus tokens --- .../kiloclaw/[instance-id]/settings/google.tsx | 14 +++++++------- .../components/agents/attachment-preview-strip.tsx | 2 +- .../components/agents/child-session-section.tsx | 14 +++++++------- .../src/components/agents/connectivity-banner.tsx | 2 +- .../src/components/agents/markdown-palette.test.ts | 1 + .../components/agents/message-error-boundary.tsx | 4 ++-- .../src/components/agents/model-picker-content.tsx | 6 +++--- .../components/agents/session-status-indicator.tsx | 6 ++---- .../agents/tool-cards/bash-tool-card.tsx | 2 +- .../agents/tool-cards/edit-tool-card.tsx | 2 +- .../agents/tool-cards/generic-tool-card.tsx | 2 +- .../agents/tool-cards/glob-tool-card.tsx | 2 +- .../agents/tool-cards/grep-tool-card.tsx | 2 +- .../agents/tool-cards/list-tool-card.tsx | 2 +- .../agents/tool-cards/read-tool-card.tsx | 2 +- .../agents/tool-cards/task-tool-card.tsx | 2 +- .../agents/tool-cards/todo-tool-card.tsx | 2 +- .../agents/tool-cards/web-search-tool-card.tsx | 2 +- .../agents/tool-cards/write-tool-card.tsx | 2 +- .../code-reviewer/platform-overview-screen.tsx | 2 +- .../code-reviewer/review-detail-screen.tsx | 2 +- .../code-reviewer/review-list-screen.tsx | 8 ++++---- .../src/components/consent/consent-details.tsx | 4 ++-- .../kilo-chat/message-bubble-content.tsx | 4 +--- .../src/components/kiloclaw/changelog-list.tsx | 14 +++++++------- .../src/components/kiloclaw/instance-card.tsx | 4 ++-- .../src/components/kiloclaw/model-picker.tsx | 4 ++-- .../src/components/kiloclaw/onboarding-flow.tsx | 2 +- .../kiloclaw/onboarding/identity-step.tsx | 2 +- .../src/components/kiloclaw/settings-card.tsx | 6 ++---- .../src/components/kiloclaw/version-pin-row.tsx | 6 +++--- .../kiloclaw/version-pin-status-card.tsx | 8 +++----- apps/mobile/src/components/ui/status-dot.tsx | 12 ++++++------ apps/mobile/src/global.css | 12 ++++++++++++ apps/mobile/src/lib/hooks/use-theme-colors.ts | 2 ++ 35 files changed, 85 insertions(+), 78 deletions(-) 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 4707b8f572..cd4e2455fd 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 @@ -117,7 +117,7 @@ export default function GoogleScreen() { } function handleRedeploy() { - 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', @@ -150,13 +150,13 @@ export default function GoogleScreen() { {isConnected ? 'Connected' : 'Not connected'} @@ -168,8 +168,8 @@ export default function GoogleScreen() { {!isConnected && ( {showRedeployPrompt && ( - - + + Google account disconnected. Redeploy your instance to apply the change. )} diff --git a/apps/mobile/src/components/kiloclaw/instance-card.tsx b/apps/mobile/src/components/kiloclaw/instance-card.tsx index 85e144eb5f..008cc368d4 100644 --- a/apps/mobile/src/components/kiloclaw/instance-card.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-card.tsx @@ -152,8 +152,8 @@ export function KiloClawCard({ {hasUnread ? ( - - + + {formatUnreadCount(unreadCount)} diff --git a/apps/mobile/src/components/kiloclaw/model-picker.tsx b/apps/mobile/src/components/kiloclaw/model-picker.tsx index 8fde8bdea7..e36452dc73 100644 --- a/apps/mobile/src/components/kiloclaw/model-picker.tsx +++ b/apps/mobile/src/components/kiloclaw/model-picker.tsx @@ -31,7 +31,7 @@ const AUTO_MODEL_CARDS: AutoModelCard[] = [ iconColorKey: 'agentYuki', cost: 3, performance: 3, - performanceDotColor: 'bg-purple-400', + performanceDotColor: 'bg-agent-yuki', }, { id: 'kilo-auto/balanced', @@ -42,7 +42,7 @@ const AUTO_MODEL_CARDS: AutoModelCard[] = [ iconColorKey: 'agentSky', cost: 2, performance: 2, - performanceDotColor: 'bg-blue-400', + performanceDotColor: 'bg-agent-sky', }, ]; diff --git a/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx b/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx index ef0886b21b..90bf43f525 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx @@ -418,7 +418,7 @@ export function OnboardingFlow() { } else if (isChannelsStep && !hasError) { headerRight = instanceReady ? ( - + Ready ) : ( diff --git a/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx b/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx index f4d224c40b..dcb209c391 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx @@ -68,7 +68,7 @@ function locationFeedbackClassName(status: 'validated' | 'service_unavailable' | return 'text-destructive'; } if (status === 'service_unavailable') { - return 'text-amber-700 dark:text-amber-400'; + return 'text-warn'; } return 'text-muted-foreground'; } diff --git a/apps/mobile/src/components/kiloclaw/settings-card.tsx b/apps/mobile/src/components/kiloclaw/settings-card.tsx index fa10c9aa2f..053688cf5a 100644 --- a/apps/mobile/src/components/kiloclaw/settings-card.tsx +++ b/apps/mobile/src/components/kiloclaw/settings-card.tsx @@ -262,10 +262,8 @@ export function SettingsCard({ ))} {item.configured ? ( - - - Connected - + + Connected ) : ( diff --git a/apps/mobile/src/components/kiloclaw/version-pin-row.tsx b/apps/mobile/src/components/kiloclaw/version-pin-row.tsx index 0ffc57520e..77df8a25c0 100644 --- a/apps/mobile/src/components/kiloclaw/version-pin-row.tsx +++ b/apps/mobile/src/components/kiloclaw/version-pin-row.tsx @@ -50,8 +50,8 @@ export function VersionPinRow({ {item.openclaw_version} {isLatest && ( - - latest + + latest )} @@ -96,7 +96,7 @@ export function VersionPinRow({ 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 index ca245da14a..0a2db8e947 100644 --- a/apps/mobile/src/components/kiloclaw/version-pin-status-card.tsx +++ b/apps/mobile/src/components/kiloclaw/version-pin-status-card.tsx @@ -53,17 +53,15 @@ export function VersionPinStatusCard({ )} {isPinnedByAdmin && ( - + Pinned by admin — contact your admin to change. )} ) : ( - - - Following latest - + + Following latest {latestVersion && ( diff --git a/apps/mobile/src/components/ui/status-dot.tsx b/apps/mobile/src/components/ui/status-dot.tsx index f4b3e6c49d..5862f12b91 100644 --- a/apps/mobile/src/components/ui/status-dot.tsx +++ b/apps/mobile/src/components/ui/status-dot.tsx @@ -10,13 +10,13 @@ type StatusDotProps = { 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' }, }; diff --git a/apps/mobile/src/global.css b/apps/mobile/src/global.css index 144706210c..58802a7046 100644 --- a/apps/mobile/src/global.css +++ b/apps/mobile/src/global.css @@ -47,6 +47,10 @@ --warn-tile-border: #9f661233; --danger-tile-bg: #be4e3f1a; --danger-tile-border: #be4e3f33; + --info: #2563eb; + --info-foreground: #ffffff; + --info-tile-bg: #2563eb1a; + --info-tile-border: #2563eb33; /* Per-agent hues + pre-tinted tile pairs. * The *-tile-bg (10% alpha) and *-tile-border (20% alpha) tokens exist @@ -114,6 +118,10 @@ --warn-tile-border: #f2b05f33; --danger-tile-bg: #f28b7a1a; --danger-tile-border: #f28b7a33; + --info: #60a5fa; + --info-foreground: #0e0e10; + --info-tile-bg: #60a5fa1a; + --info-tile-border: #60a5fa33; /* Per-agent hues + pre-tinted tile pairs (dark values) */ --agent-yuki: #a78bfa; @@ -175,6 +183,10 @@ --color-warn-tile-border: var(--warn-tile-border); --color-danger-tile-bg: var(--danger-tile-bg); --color-danger-tile-border: var(--danger-tile-border); + --color-info: var(--info); + --color-info-foreground: var(--info-foreground); + --color-info-tile-bg: var(--info-tile-bg); + --color-info-tile-border: var(--info-tile-border); /* Per-agent colors (Tailwind classes: bg-agent-yuki, bg-agent-yuki-tile-bg, ...) */ --color-agent-yuki: var(--agent-yuki); diff --git a/apps/mobile/src/lib/hooks/use-theme-colors.ts b/apps/mobile/src/lib/hooks/use-theme-colors.ts index dde8cb8358..5d9ae9d8f6 100644 --- a/apps/mobile/src/lib/hooks/use-theme-colors.ts +++ b/apps/mobile/src/lib/hooks/use-theme-colors.ts @@ -24,6 +24,7 @@ const lightColors = { accentSoftForeground: '#1A1A10', 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', From 9e3a15e48364d56f0942f81d5dfb39a0339747ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 16:11:38 +0200 Subject: [PATCH 125/166] fix(mobile): UX copy and date formatting consistency --- .../(1_kiloclaw)/chat/instance-picker.tsx | 2 +- .../app/(app)/(tabs)/(1_kiloclaw)/index.tsx | 2 +- .../[scope]/[platform]/focus-areas.tsx | 2 +- .../[scope]/[platform]/instructions.tsx | 2 +- .../code-reviewer/[scope]/[platform]/style.tsx | 2 +- .../src/app/(app)/agent-chat/mode-picker.tsx | 2 +- apps/mobile/src/app/(app)/agent-chat/new.tsx | 6 +++--- .../src/app/(app)/agent-chat/repo-picker.tsx | 4 ++-- .../(app)/kiloclaw/[instance-id]/changelog.tsx | 4 ++-- .../(app)/kiloclaw/[instance-id]/dashboard.tsx | 6 +++--- .../[instance-id]/settings/channels.tsx | 2 +- .../[instance-id]/settings/device-pairing.tsx | 18 +++++++++--------- .../[instance-id]/settings/exec-policy.tsx | 12 ++++++------ .../[instance-id]/settings/model-list.tsx | 2 +- .../[instance-id]/settings/secrets.tsx | 2 +- .../[instance-id]/settings/version-pin.tsx | 12 ++++++------ .../src/components/agents/permission-card.tsx | 2 +- .../agents/platform-filter-modal.tsx | 2 +- .../src/components/agents/question-card.tsx | 4 ++-- .../components/agents/session-list-content.tsx | 2 +- .../src/components/agents/session-row.tsx | 2 +- .../code-reviewer/manual-review-screen.tsx | 4 ++-- .../src/components/force-update-screen.tsx | 4 ++-- .../kilo-chat/conversation-list-screen.tsx | 2 +- .../kilo-chat/conversation-screen.tsx | 2 +- .../src/components/kiloclaw/billing-banner.tsx | 2 +- .../kiloclaw/empty-state-content.tsx | 2 +- .../components/kiloclaw/instance-controls.tsx | 6 +++--- .../kiloclaw/instance-list-screen.tsx | 8 ++++---- .../kiloclaw/onboarding/notifications-step.tsx | 2 +- .../kiloclaw/onboarding/provisioning-step.tsx | 2 +- .../src/components/kiloclaw/settings-list.tsx | 6 +++--- apps/mobile/src/components/login-screen.tsx | 4 ++-- .../src/components/notifications-card.tsx | 6 +++--- .../src/components/organization/hub-screen.tsx | 2 +- apps/mobile/src/components/profile-screen.tsx | 8 ++++---- .../analysis-settings-screen.tsx | 6 +++--- .../automation-settings-screen.tsx | 6 +++--- .../security-agent/dismiss-finding-screen.tsx | 14 +++++++------- .../security-agent/finding-filter-modal.tsx | 2 +- .../security-agent/finding-list-screen.tsx | 2 +- .../notification-settings-screen.tsx | 4 ++-- .../security-agent/sla-settings-screen.tsx | 6 +++--- .../lib/kilo-pass/subscription-page-copy.ts | 2 +- 44 files changed, 97 insertions(+), 97 deletions(-) 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 b17e69d499..ae95239800 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 @@ -73,7 +73,7 @@ export default function InstancePickerScreen() { return ( { router.back(); }} 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 2b1d4a7dcb..1ae36ea5c6 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx @@ -52,7 +52,7 @@ export default function KiloClawTab() { try { const result = await refetchInstances(); if (result.isError) { - toast.error('Could not refresh — showing the last saved list.'); + toast.error('Could not refresh. Showing the last saved list.'); } } finally { setManualRefreshing(false); 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]/focus-areas.tsx index 3af139a48e..5a4705c98d 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]/focus-areas.tsx @@ -57,7 +57,7 @@ function FocusAreasRouteContent({ return ( - + Leave all unselected to review everything. 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]/instructions.tsx index 928197d10c..5f9c2ebc5f 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]/instructions.tsx @@ -83,7 +83,7 @@ function InstructionsRouteContent({ return ( - + { router.back(); }} diff --git a/apps/mobile/src/app/(app)/agent-chat/new.tsx b/apps/mobile/src/app/(app)/agent-chat/new.tsx index 21af2ee682..ff9b2f0f2b 100644 --- a/apps/mobile/src/app/(app)/agent-chat/new.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/new.tsx @@ -291,7 +291,7 @@ export default function NewSessionScreen() { return ( - + - Open + Open GitHub 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 0e34fc694b..1b0bf3acca 100644 --- a/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx @@ -58,7 +58,7 @@ export default function RepoPickerScreen() { if (!bridge) { return ( + - Recent Updates + Recent updates result.isError)) { - toast.error('Could not refresh — showing the last known status.'); + toast.error('Could not refresh. Showing the last known status.'); } } finally { setManualRefreshing(false); @@ -163,7 +163,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' }, @@ -306,7 +306,7 @@ export default function DashboardScreen() { {renameVisible && ( { 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 47c14a0239..addac50377 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 @@ -95,7 +95,7 @@ export default function ChannelsScreen() { key={channel.id} item={channel} mutations={mutations} - removeAlertTitle="Disconnect Channel" + removeAlertTitle="Disconnect channel" removeAlertMessage={`Remove ${channel.label}? This channel will be disconnected.`} successMessage={`${channel.label} connected`} /> 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 4d92d3e2b4..8769fec605 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 @@ -86,7 +86,7 @@ export default function DevicePairingScreen() { if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { return ( - + ); @@ -95,7 +95,7 @@ export default function DevicePairingScreen() { if (isLoading) { return ( - + @@ -108,7 +108,7 @@ export default function DevicePairingScreen() { if (pairingQuery.isError || devicePairingQuery.isError) { return ( - + - + - + 0 && ( - Channel Requests + Channel requests {channelRequests.map((request, index) => { @@ -248,7 +248,7 @@ export default function DevicePairingScreen() { {deviceRequests.length > 0 && ( - Device Requests + Device requests {deviceRequests.map((request, index) => { 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 8c9c2b034f..a7003e8a92 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 @@ -28,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.', }, ]; @@ -68,7 +68,7 @@ export default function ExecPolicyScreen() { if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { return ( - + ); @@ -77,7 +77,7 @@ export default function ExecPolicyScreen() { if (statusQuery.isPending) { return ( - + @@ -93,7 +93,7 @@ export default function ExecPolicyScreen() { if (statusQuery.isError) { 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 dedfbaa5d1..3f8d6e563d 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 @@ -95,7 +95,7 @@ export default function SecretsScreen() { key={secret.id} item={secret} mutations={mutations} - removeAlertTitle="Remove Secret" + removeAlertTitle="Remove secret" removeAlertMessage={`Remove ${secret.label}? This tool will lose access to its credentials.`} successMessage={`${secret.label} saved`} /> 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 877ac00b16..56a1d61bea 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 @@ -49,7 +49,7 @@ export default function VersionPinScreen() { if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { return ( - + ); @@ -58,7 +58,7 @@ export default function VersionPinScreen() { if (isLoading) { return ( - + @@ -81,7 +81,7 @@ export default function VersionPinScreen() { ) { return ( - + - + 0 && ( - Available Versions + Available versions )} 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 a1b20155db..45e28cb188 100644 --- a/apps/mobile/src/components/agents/platform-filter-modal.tsx +++ b/apps/mobile/src/components/agents/platform-filter-modal.tsx @@ -199,7 +199,7 @@ export function SessionFilterModal({ 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 793ea4e9f7..5952594662 100644 --- a/apps/mobile/src/components/agents/question-card.tsx +++ b/apps/mobile/src/components/agents/question-card.tsx @@ -116,7 +116,7 @@ export function QuestionCard({ } 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 }, ]); @@ -127,7 +127,7 @@ export function QuestionCard({ return ( - Agent Needs Input + Agent needs input diff --git a/apps/mobile/src/components/agents/session-list-content.tsx b/apps/mobile/src/components/agents/session-list-content.tsx index 7a473affeb..aa76795deb 100644 --- a/apps/mobile/src/components/agents/session-list-content.tsx +++ b/apps/mobile/src/components/agents/session-list-content.tsx @@ -169,7 +169,7 @@ export function AgentSessionListContent({ try { const hadError = await refetch(); if (hadError) { - toast.error('Could not refresh sessions — showing the last saved list.'); + toast.error('Could not refresh sessions. Showing the last saved list.'); } } finally { setRefreshing(false); diff --git a/apps/mobile/src/components/agents/session-row.tsx b/apps/mobile/src/components/agents/session-row.tsx index ff21b68c76..f6db6e2c85 100644 --- a/apps/mobile/src/components/agents/session-row.tsx +++ b/apps/mobile/src/components/agents/session-row.tsx @@ -182,7 +182,7 @@ export function StoredSessionRow({ > - Rename Session + Rename session { 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 94bc274b70..60200d693d 100644 --- a/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx @@ -109,7 +109,7 @@ export function ManualReviewScreen({ scope }: Readonly<{ scope: string }>) { if (!statusesLoading && !isConnected('github') && !isConnected('gitlab')) { return ( - + ) { return ( - + - Update Required + Update required A new version of Kilo is available. Please update to continue. {storeOpenFailed && ( 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 c279a90701..f70e35568f 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx @@ -173,7 +173,7 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { try { const result = await refetchConversations(); if (result.isError) { - toast.error('Could not refresh — showing the last saved list.'); + toast.error('Could not refresh. Showing the last saved list.'); } } finally { setManualRefreshing(false); diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index 1e473ceffe..153e35ef5d 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -169,7 +169,7 @@ export function ConversationScreen({ 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'); diff --git a/apps/mobile/src/components/kiloclaw/billing-banner.tsx b/apps/mobile/src/components/kiloclaw/billing-banner.tsx index bf1a09d0cd..97c3bfd675 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/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-controls.tsx b/apps/mobile/src/components/kiloclaw/instance-controls.tsx index fabf194dda..20028163e6 100644 --- a/apps/mobile/src/components/kiloclaw/instance-controls.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-controls.tsx @@ -47,7 +47,7 @@ export function InstanceControls({ status, mutations }: Readonly { - 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', @@ -60,7 +60,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', @@ -87,7 +87,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', diff --git a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx index 2ebeff6d91..7299b8c56f 100644 --- a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx @@ -34,10 +34,10 @@ type Props = { // 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', + 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', }; diff --git a/apps/mobile/src/components/kiloclaw/onboarding/notifications-step.tsx b/apps/mobile/src/components/kiloclaw/onboarding/notifications-step.tsx index cae5d89039..35eb9d4ec3 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding/notifications-step.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding/notifications-step.tsx @@ -116,7 +116,7 @@ export function NotificationsStep({ onComplete, botIdentity }: Readonly - `We lost the connection while checking on ${name}'s setup. It may still be running — try again, or check back shortly.`, + `We lost the connection while checking on ${name}'s setup. It may still be running. Try again, or check back shortly.`, }, instance_stopped: { title: 'Setup stopped', diff --git a/apps/mobile/src/components/kiloclaw/settings-list.tsx b/apps/mobile/src/components/kiloclaw/settings-list.tsx index 0031323846..48686a6be1 100644 --- a/apps/mobile/src/components/kiloclaw/settings-list.tsx +++ b/apps/mobile/src/components/kiloclaw/settings-list.tsx @@ -41,19 +41,19 @@ const SETTINGS_ITEMS: SettingsItem[] = [ }, { icon: Link2, - label: 'Device Pairing', + label: 'Device pairing', description: 'Approve device requests', path: 'settings/device-pairing', }, { icon: Shield, - label: 'Execution Policy', + label: 'Execution policy', description: 'Security settings', path: 'settings/exec-policy', }, { icon: Pin, - label: 'Version Pinning', + label: 'Version pinning', description: 'Pin to a specific version', path: 'settings/version-pin', }, diff --git a/apps/mobile/src/components/login-screen.tsx b/apps/mobile/src/components/login-screen.tsx index 983c42ceb1..2992119d06 100644 --- a/apps/mobile/src/components/login-screen.tsx +++ b/apps/mobile/src/components/login-screen.tsx @@ -129,7 +129,7 @@ export function LoginScreen() { accessibilityLabel="Open sign-in page in browser" > - Open + Open in browser + ); } diff --git a/apps/mobile/src/components/agents/platform-filter-modal.tsx b/apps/mobile/src/components/agents/platform-filter-modal.tsx index 45e28cb188..e5c2a9e76b 100644 --- a/apps/mobile/src/components/agents/platform-filter-modal.tsx +++ b/apps/mobile/src/components/agents/platform-filter-modal.tsx @@ -191,9 +191,20 @@ export function SessionFilterModal({ return ( - + { e.stopPropagation(); 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 60200d693d..773458bd3b 100644 --- a/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx @@ -7,6 +7,7 @@ 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'; @@ -59,6 +60,7 @@ export function ManualReviewScreen({ scope }: Readonly<{ scope: string }>) { 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'; @@ -106,7 +108,25 @@ export function ManualReviewScreen({ scope }: Readonly<{ scope: string }>) { ); }; - if (!statusesLoading && !isConnected('github') && !isConnected('gitlab')) { + 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 ( diff --git a/apps/mobile/src/components/code-reviewer/platform-error-screen.tsx b/apps/mobile/src/components/code-reviewer/platform-error-screen.tsx index e25f909f85..e2ce57e310 100644 --- a/apps/mobile/src/components/code-reviewer/platform-error-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/platform-error-screen.tsx @@ -14,7 +14,7 @@ export function PlatformErrorScreen({ - + ); diff --git a/apps/mobile/src/components/login/idle-auth.tsx b/apps/mobile/src/components/login/idle-auth.tsx index dcaf482853..46d75cd27c 100644 --- a/apps/mobile/src/components/login/idle-auth.tsx +++ b/apps/mobile/src/components/login/idle-auth.tsx @@ -93,7 +93,7 @@ export function IdleAuth({ void (async () => { const ok = await requestEmailCode(emailRef.current); if (ok) { - toast('Code sent'); + toast.success('Code sent'); } })(); }} diff --git a/apps/mobile/src/components/profile-credits-card.tsx b/apps/mobile/src/components/profile-credits-card.tsx index a9dcfceb5f..1e9678a585 100644 --- a/apps/mobile/src/components/profile-credits-card.tsx +++ b/apps/mobile/src/components/profile-credits-card.tsx @@ -13,13 +13,14 @@ 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 { isMoneyRole, type OrgListEntry } from '@/lib/hooks/use-organization-queries'; import { useOrganization } from '@/lib/organization-context'; import { useTRPC } from '@/lib/trpc'; 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) { @@ -76,8 +77,11 @@ export function CreditsCard({ enabled, orgs }: Readonly) { // 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), matching the always-on CTA on the organization hub screen. - const canShowZeroBalanceCta = selectedOrgId != null || Platform.OS !== 'ios'; + // 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`; 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 9fc9e80b49..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} From b1601cf57d430ea02480fb7054e73954e25c416f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 23:51:22 +0200 Subject: [PATCH 131/166] feat(kilo-chat): redeliver failed messages on retry instead of recreating them Retry-send previously composed a brand-new message from the failed one: attachment blocks reused already-linked attachment IDs (server rejects with 'Attachment is already linked', so retries with attachments always failed), and the original row kept delivery_failed=1 server-side, resurfacing next to the successful retry on any refetch. New conversation-DO redeliverMessage op re-enqueues delivery of the existing row: clears delivery_failed, reuses the stored content and reply context, touches no attachments, and is an idempotent no-op when the message is not in a failed state. Human members receive a message.redelivered event (mirror of message.delivery_failed) so all clients clear the flag; the mobile retry action now calls it via useRedeliverMessage with an optimistic flag clear. --- .../use-conversation-message-controller.ts | 26 ++--- .../kilo-chat/message-presentation.test.ts | 36 ------- .../kilo-chat/message-presentation.ts | 7 -- packages/kilo-chat-hooks/src/use-messages.ts | 49 ++++++++++ packages/kilo-chat/src/client.ts | 12 +++ packages/kilo-chat/src/events.ts | 6 ++ packages/kilo-chat/src/types.ts | 2 + .../src/__tests__/conversation-do.test.ts | 98 +++++++++++++++++++ services/kilo-chat/src/__tests__/helpers.ts | 5 + .../src/__tests__/messages-routes.test.ts | 52 ++++++++++ services/kilo-chat/src/do/conversation-do.ts | 97 ++++++++++++++++++ services/kilo-chat/src/index.ts | 2 + services/kilo-chat/src/routes/handler.ts | 30 ++++++ 13 files changed, 362 insertions(+), 60 deletions(-) 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 ed85ed2753..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,6 +1,5 @@ -import { useQueryClient } from '@tanstack/react-query'; import * as Haptics from 'expo-haptics'; -import { messagesKey, removeMessageFromCache, useEditMessage } from '@kilocode/kilo-chat-hooks'; +import { useEditMessage, useRedeliverMessage } from '@kilocode/kilo-chat-hooks'; import { buildMessageEditContent, contentBlocksToText, @@ -17,7 +16,6 @@ import { captureEvent, MESSAGE_SENT_EVENT } from '@/lib/analytics/posthog'; import { resolveMobileMessageInputAvailability } from '../bot-send-state'; import { type MessageInputSubmitControls } from '../message-input-state'; import { - buildRetrySendContent, buildSendMessageVariables, createSendMessageClientId, getEditableAttachmentBlocks, @@ -60,9 +58,9 @@ export function useConversationMessageController({ const [replyingTo, setReplyingTo] = useState(null); const [scrollToNewestRequest, setScrollToNewestRequest] = useState(0); - const queryClient = useQueryClient(); const sendMutation = useSendMessage(client, conversationId, currentUserId); const editMessage = useEditMessage(client, conversationId); + const redeliverMessage = useRedeliverMessage(client, conversationId); const editingTextValue = useMemo( () => (editingMessage ? editableText(editingMessage) : ''), [editingMessage] @@ -98,28 +96,22 @@ export function useConversationMessageController({ const handleRetrySend = useCallback( (message: Message) => { - sendMutation.mutate( - buildSendMessageVariables({ - conversationId, - content: buildRetrySendContent(message), - clientId: createSendMessageClientId(), - inReplyToMessageId: message.inReplyToMessageId ?? undefined, - }), + // 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: () => { - // The resend settled a brand-new message row; drop the original - // failed row so retrying doesn't leave a permanent "Not - // delivered" duplicate alongside the delivered message. - removeMessageFromCache(queryClient, messagesKey(conversationId), message.id); void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); }, onError: err => { - toast.error(formatKiloChatError(err, 'Failed to send message')); + toast.error(formatKiloChatError(err, 'Failed to retry send')); }, } ); }, - [conversationId, queryClient, sendMutation] + [redeliverMessage] ); const messageActions = useConversationMessageActions({ 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 c118a92b09..4c12bb8234 100644 --- a/apps/mobile/src/components/kilo-chat/message-presentation.test.ts +++ b/apps/mobile/src/components/kilo-chat/message-presentation.test.ts @@ -5,7 +5,6 @@ import { describe, expect, it, vi } from 'vitest'; import { createMessageRequestSchema, type Message } from '@kilocode/kilo-chat'; import { - buildRetrySendContent, buildSendMessageVariables, canCopyMessage, canRetryFailedMessage, @@ -254,41 +253,6 @@ describe('canRetryFailedMessage', () => { }); }); -describe('buildRetrySendContent', () => { - it('rebuilds a single joined text block plus attachments', () => { - const attachment = { - type: 'attachment', - attachmentId: '01HV0000000000000000000001', - mimeType: 'image/png', - size: 123, - filename: 'photo.png', - } as const; - - expect( - buildRetrySendContent( - message({ - content: [{ type: 'text', text: 'hello' }, attachment], - deliveryFailed: true, - }) - ) - ).toEqual([{ type: 'text', text: 'hello' }, attachment]); - }); - - it('drops an empty text block, keeping attachment-only content', () => { - const attachment = { - type: 'attachment', - attachmentId: '01HV0000000000000000000001', - mimeType: 'image/png', - size: 123, - filename: 'photo.png', - } as const; - - expect(buildRetrySendContent(message({ content: [attachment], deliveryFailed: true }))).toEqual( - [attachment] - ); - }); -}); - 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 7c64b6fbc6..c47c2550f6 100644 --- a/apps/mobile/src/components/kilo-chat/message-presentation.ts +++ b/apps/mobile/src/components/kilo-chat/message-presentation.ts @@ -96,13 +96,6 @@ export function canRetryFailedMessage(message: Message): boolean { return !message.deleted && message.deliveryFailed; } -/** Reconstructs the original send content from a delivery-failed message, for retry. */ -export function buildRetrySendContent(message: Message): InputContentBlock[] { - const text = contentBlocksToText(message.content).trim(); - const textBlocks: InputContentBlock[] = text.length > 0 ? [{ type: 'text', text }] : []; - return [...textBlocks, ...getEditableAttachmentBlocks(message)]; -} - function firstDisplayValue(values: readonly (string | null | undefined)[]): string | null { for (const value of values) { const trimmed = value?.trim(); diff --git a/packages/kilo-chat-hooks/src/use-messages.ts b/packages/kilo-chat-hooks/src/use-messages.ts index 98e5189fa7..3debd3d52e 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,45 @@ 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), so the cache + * update is just an optimistic clear of `deliveryFailed`. If the retry fails + * permanently again the server pushes a fresh `message.delivery_failed` event. + */ +export function useRedeliverMessage(client: KiloChatClient, conversationId: string | null) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ messageId }: { messageId: string }) => + client.redeliverMessage(conversationId ?? '', messageId), + onMutate: async variables => { + if (!conversationId) return; + const queryKey = messagesKey(conversationId); + await queryClient.cancelQueries({ queryKey }); + const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); + const optimisticMessage = snapshot ? { ...snapshot, deliveryFailed: false } : undefined; + queryClient.setQueryData(queryKey, old => { + if (!old) return old; + return updateMessageInPages(old, variables.messageId, msg => ({ + ...msg, + deliveryFailed: false, + })); + }); + return { queryKey, snapshot, optimisticMessage }; + }, + onError: (_err, _variables, context) => { + if (!context) return; + const restored = restoreOptimisticMessage( + queryClient, + context.queryKey, + context.snapshot, + context.optimisticMessage + ); + if (!restored) invalidateMessages(queryClient, context.queryKey); + }, + }); +} + export function useDeleteMessage(client: KiloChatClient, conversationId: string | null) { const queryClient = useQueryClient(); return useMutation({ @@ -1015,6 +1055,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 +1100,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..834598e054 100644 --- a/services/kilo-chat/src/__tests__/conversation-do.test.ts +++ b/services/kilo-chat/src/__tests__/conversation-do.test.ts @@ -1130,4 +1130,102 @@ 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, + memberContext: { humanMemberIds: ['user-alice'], sandboxId: null }, + }); + + 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 - 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..1ca819b2e0 100644 --- a/services/kilo-chat/src/do/conversation-do.ts +++ b/services/kilo-chat/src/do/conversation-do.ts @@ -255,6 +255,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; memberContext: MemberContext } + | { ok: false; code: 'not_found' | 'forbidden'; error: string }; + export type InitAttachmentParams = { uploaderId: string; mimeType: string; @@ -444,6 +453,94 @@ 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 and 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, memberContext }; + } + + this.db + .update(messages) + .set({ delivery_failed: 0 }) + .where(eq(messages.id, params.messageId)) + .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 = parseStoredContent(parent.content, parent.id) + .filter((b): b is Extract => b.type === 'text') + .map(b => b.text) + .join(''); + inReplyToSender = parent.sender_id; + } + } + + const sentAt = new Date().toISOString(); + for (const bot of activeMembers.filter(member => member.kind === 'bot')) { + await this.enqueueMessageWebhook( + { + 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 }), + }, + memberContext + ); + } + + return { ok: true, redelivered: true, memberContext }; + } + 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..8e47ab5d20 100644 --- a/services/kilo-chat/src/routes/handler.ts +++ b/services/kilo-chat/src/routes/handler.ts @@ -251,6 +251,36 @@ 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); + return c.json({ error: result.error }, 404); + } + + if (result.redelivered && result.memberContext.sandboxId) { + const pushPromise = pushEventToHumanMembers( + c.env, + convId.data, + result.memberContext.sandboxId, + result.memberContext.humanMemberIds, + 'message.redelivered', + { messageId: msgId.data } + ); + c.executionCtx.waitUntil(pushPromise); + } + return c.json({ ok: true } satisfies OkResponse); +} + // ─── messageDeliveryFailed (bot-reported) ─────────────────────────────────── export async function handleMessageDeliveryFailed(c: HonoCtx) { From f0b11e9e7fa17602790f00efeb43762f9d7ef039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 23:51:32 +0200 Subject: [PATCH 132/166] fix(mobile): tear down Sentry before consent re-init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sentry.init in @sentry/react-native 7.x binds a new client without closing the previous one, and replay sampling is only read at init — so revoking consent left an in-flight replay recording and accumulated clients on every transition. The SDK exposes no runtime replay start/stop (native bridge only has captureReplay/getCurrentReplayId), so consent transitions now await Sentry.close() before re-initing, serialized through a lifecycle promise so a fast accept-revoke cannot interleave. Also drops the redundant duplicate init on mount. --- apps/mobile/src/app/_layout.tsx | 17 ++++--- apps/mobile/src/lib/sentry-consent.test.ts | 58 +++++++++++++++++++++- apps/mobile/src/lib/sentry-consent.ts | 29 ++++++++++- 3 files changed, 93 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 5a55424165..4ec96928c5 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -40,7 +40,7 @@ import { setupNotificationResponseHandler, } from '@/lib/notifications'; import { resolvePendingNotificationNavigation } from '@/lib/pending-notification-navigation'; -import { sentryOptionsForConsent } from '@/lib/sentry-consent'; +import { reinitSentryForConsent, sentryOptionsForConsent } from '@/lib/sentry-consent'; const navigationIntegration = Sentry.reactNavigationIntegration({ enableTimeToInitialDisplay: !isRunningInExpoGo(), @@ -48,10 +48,12 @@ const navigationIntegration = Sentry.reactNavigationIntegration({ // 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 decides whether -// those integrations run once, at Sentry.init() time, so `consented` starts -// `false` and RootLayoutNav below calls this again once consent is known or -// changes (accepted, declined, or revoked). +// 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', @@ -106,7 +108,8 @@ function RootLayoutNav() { } }, [fontsError]); - const appliedSentryConsentRef = useRef(null); + // Starts `false` because module scope above already ran initSentry(false). + const appliedSentryConsentRef = useRef(false); useEffect(() => { const consented = consentChecked && !needsConsent; @@ -115,7 +118,7 @@ function RootLayoutNav() { } appliedSentryConsentRef.current = consented; - initSentry(consented); + void reinitSentryForConsent(consented, initSentry); }, [consentChecked, needsConsent]); const fontsReady = fontsLoaded || fontsError !== null; diff --git a/apps/mobile/src/lib/sentry-consent.test.ts b/apps/mobile/src/lib/sentry-consent.test.ts index b65a31e5a6..9aeeef3f2f 100644 --- a/apps/mobile/src/lib/sentry-consent.test.ts +++ b/apps/mobile/src/lib/sentry-consent.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { sentryOptionsForConsent } from './sentry-consent'; +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', () => { @@ -21,3 +25,53 @@ describe('sentryOptionsForConsent', () => { 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 index d1ec6b79d3..4c14cafcfa 100644 --- a/apps/mobile/src/lib/sentry-consent.ts +++ b/apps/mobile/src/lib/sentry-consent.ts @@ -1,8 +1,11 @@ +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 calls Sentry.init again -// with these options whenever the stored consent state changes. +// 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; @@ -27,3 +30,25 @@ export function sentryOptionsForConsent(consented: boolean): SentryConsentOption 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 through `lifecycle` +// so a fast accept → revoke can't interleave close and init. +let lifecycle: Promise | undefined = undefined; + +export async function reinitSentryForConsent( + consented: boolean, + init: (consented: boolean) => void +): Promise { + const previous = lifecycle; + lifecycle = (async () => { + await previous; + await Sentry.close(); + init(consented); + })(); + await lifecycle; +} From c2d5fd52ba34a6af4e5e35ff1dcf27ffe160fbcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 23:51:45 +0200 Subject: [PATCH 133/166] fix(mobile): address review findings across composer, sessions, kiloclaw, security agent, login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chat composer: lock text input and attachment controls while a send is in flight so the success path cannot wipe a draft typed during the await - session mutations: key the pending guard by operation (and rename title) instead of bare session id, so a delete during a settling rename is no longer silently dropped - new session: only replace the repo/model selectors with an error state when there is no cached data; background refetch failures keep selectors - version pin: treat cached null (valid unpinned state) as data — only data === undefined is an initial-load failure - instance controls: block Start/Redeploy during recovering/restoring, which the backend rejects anyway - security-agent scope list: an organizations.list failure degrades only the org section; the Personal scope row stays reachable - remediation cancel: distinguish 'Cancellation requested' (attempt already running) from 'Remediation cancelled' (queued attempt removed) - kiloclaw onboarding: retry from the instance_stopped terminal state now fires the start mutation — previously it looped back to the same state - device auth: cancel aborts the in-flight device-code POST and a resolving request can no longer revive a cancelled flow; the 15s timeout owns its error state --- apps/mobile/src/app/(app)/agent-chat/new.tsx | 4 +- .../[instance-id]/settings/version-pin.tsx | 9 +-- .../src/components/agents/chat-composer.tsx | 4 +- .../components/kiloclaw/instance-controls.tsx | 4 ++ .../components/kiloclaw/onboarding-flow.tsx | 10 ++- .../security-agent/scope-list-screen.tsx | 68 ++++++++++--------- apps/mobile/src/lib/auth/use-device-auth.ts | 25 ++++++- .../src/lib/hooks/use-security-findings.ts | 10 ++- .../src/lib/hooks/use-session-mutations.ts | 18 +++-- 9 files changed, 101 insertions(+), 51 deletions(-) diff --git a/apps/mobile/src/app/(app)/agent-chat/new.tsx b/apps/mobile/src/app/(app)/agent-chat/new.tsx index ff9b2f0f2b..c7d39d1e4a 100644 --- a/apps/mobile/src/app/(app)/agent-chat/new.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/new.tsx @@ -355,7 +355,7 @@ export default function NewSessionScreen() { autoFocus /> - {isModelsError ? ( + {isModelsError && models.length === 0 ? ( Repository - {isReposError ? ( + {isReposError && repoData === undefined ? ( diff --git a/apps/mobile/src/components/agents/chat-composer.tsx b/apps/mobile/src/components/agents/chat-composer.tsx index c1b910d141..7df0ea92e3 100644 --- a/apps/mobile/src/components/agents/chat-composer.tsx +++ b/apps/mobile/src/components/agents/chat-composer.tsx @@ -93,7 +93,9 @@ export function ChatComposer({ // The backend requires a non-empty prompt even when attachments are present. 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; diff --git a/apps/mobile/src/components/kiloclaw/instance-controls.tsx b/apps/mobile/src/components/kiloclaw/instance-controls.tsx index 20028163e6..46b98d5e4e 100644 --- a/apps/mobile/src/components/kiloclaw/instance-controls.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-controls.tsx @@ -21,6 +21,8 @@ const START_BLOCKING_STATUSES = new Set([ 'stopping', 'shutting_down', 'destroying', + 'recovering', + 'restoring', ]); const REDEPLOY_BLOCKING_STATUSES = new Set([ 'starting', @@ -28,6 +30,8 @@ const REDEPLOY_BLOCKING_STATUSES = new Set([ 'stopping', 'shutting_down', 'destroying', + 'recovering', + 'restoring', ]); export function InstanceControls({ status, mutations }: Readonly) { diff --git a/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx b/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx index 90bf43f525..ab6c38d81d 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx @@ -175,10 +175,18 @@ export function OnboardingFlow() { // skip re-provisioning: the DO row is live and the subsequent `channels-skipped` // step-save effects will re-apply any identity / exec-preset changes idempotently. const alreadyProvisioned = state.provisionSuccess && state.sandboxId !== null; + const instanceStopped = state.instanceStatus === 'stopped'; + const startInstanceMutate = mutations.start.mutate; const handleStart = useCallback( (userLocation: string | null) => { if (alreadyProvisioned) { dispatch({ type: 'start-requested' }); + // A stopped instance never restarts on its own — without this the + // `instance_stopped` terminal state's "Try again" just loops back + // to itself (nothing re-provisions, nothing starts). + if (instanceStopped) { + startInstanceMutate(undefined); + } return; } dispatch({ type: 'start-requested' }); @@ -208,7 +216,7 @@ export function OnboardingFlow() { } ); }, - [alreadyProvisioned, mutations.provision] + [alreadyProvisioned, instanceStopped, startInstanceMutate, mutations.provision] ); // Save the bot identity to the instance as soon as both the user has diff --git a/apps/mobile/src/components/security-agent/scope-list-screen.tsx b/apps/mobile/src/components/security-agent/scope-list-screen.tsx index 06282e4431..a2550bc1cc 100644 --- a/apps/mobile/src/components/security-agent/scope-list-screen.tsx +++ b/apps/mobile/src/components/security-agent/scope-list-screen.tsx @@ -8,7 +8,7 @@ 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, useTabBarBottomPadding } from '@/components/tab-screen'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { getSecurityAgentPath } from '@/lib/security-agent'; import { useTRPC } from '@/lib/trpc'; @@ -16,7 +16,6 @@ import { useTRPC } from '@/lib/trpc'; // org members to operate Security Agent, so members are not labeled "View only". export function ScopeListScreen() { const router = useRouter(); - const paddingBottom = useTabBarBottomPadding(); const trpc = useTRPC(); const { data: orgs, @@ -30,21 +29,38 @@ export function ScopeListScreen() { router.push(getSecurityAgentPath(scope)); }; - if (isError && !orgs) { - return ( - - - - void refetch()} - isRetrying={isFetching} - /> - - - ); - } + // 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 ( @@ -57,23 +73,9 @@ export function ScopeListScreen() { onPress={() => { 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/lib/auth/use-device-auth.ts b/apps/mobile/src/lib/auth/use-device-auth.ts index d26ba4d37c..878b0b36e4 100644 --- a/apps/mobile/src/lib/auth/use-device-auth.ts +++ b/apps/mobile/src/lib/auth/use-device-auth.ts @@ -172,9 +172,20 @@ 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 { @@ -184,6 +195,12 @@ export function useDeviceAuth(): DeviceAuthResult { signal: startAbort.signal, }); + // 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({ status: 'error', @@ -226,7 +243,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, diff --git a/apps/mobile/src/lib/hooks/use-security-findings.ts b/apps/mobile/src/lib/hooks/use-security-findings.ts index 315e897d18..d22ceac9b9 100644 --- a/apps/mobile/src/lib/hooks/use-security-findings.ts +++ b/apps/mobile/src/lib/hooks/use-security-findings.ts @@ -319,8 +319,14 @@ export function useCancelSecurityRemediation(scope: string) { } toast.error(error.message); }, - onSuccess: () => { - toast.success('Remediation cancelled'); + 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( diff --git a/apps/mobile/src/lib/hooks/use-session-mutations.ts b/apps/mobile/src/lib/hooks/use-session-mutations.ts index 03c42b55ad..2423c2da63 100644 --- a/apps/mobile/src/lib/hooks/use-session-mutations.ts +++ b/apps/mobile/src/lib/hooks/use-session-mutations.ts @@ -75,32 +75,36 @@ export function useSessionMutations() { // Optimistic updates already make the row change land instantly, so a // second tap before the first mutation settles would just be a duplicate - // in-flight request for the same row — drop it instead of firing another. - const withPendingGuard = (sessionId: string, run: () => Promise) => { - if (pendingSessionIds.current.has(sessionId)) { + // in-flight request for the same operation — drop it instead of firing + // another. Keyed by operation + session so a delete issued while a rename + // settles (or vice versa) still goes through. + const withPendingGuard = (pendingKey: string, run: () => Promise) => { + if (pendingSessionIds.current.has(pendingKey)) { return; } - pendingSessionIds.current.add(sessionId); + pendingSessionIds.current.add(pendingKey); void (async () => { try { await run(); } catch { // Already surfaced via the mutation's own onError (toast + rollback). } finally { - pendingSessionIds.current.delete(sessionId); + pendingSessionIds.current.delete(pendingKey); } })(); }; return { deleteSession: (sessionId: string) => { - withPendingGuard(sessionId, async () => { + withPendingGuard(`delete:${sessionId}`, async () => { const result = await deleteSessionMutation.mutateAsync({ session_id: sessionId }); return result; }); }, renameSession: (sessionId: string, title: string) => { - withPendingGuard(sessionId, async () => { + // Title in the key: a re-rename to a different title is a new + // operation, only the identical duplicate gets dropped. + withPendingGuard(`rename:${sessionId}:${title}`, async () => { const result = await renameSessionMutation.mutateAsync({ session_id: sessionId, title }); return result; }); From 58a0bf222ef9e0db5b73476a73793813fe6465e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 11 Jul 2026 23:51:58 +0200 Subject: [PATCH 134/166] fix(mobile): session search retry, reduced-motion pulse, header touch targets - failed session search shows a retryable QueryError (Clear search kept below) instead of a dead-end no-match state; pull-to-refresh retries the search query too while one is active - kiloclaw provisioning breathing pulse goes static under Reduce Motion, matching Skeleton/SpinningIcon - header icon buttons (session-list +/filter, dashboard rename, device pairing refresh) get hitSlop to reach 44pt effective targets --- .../kiloclaw/[instance-id]/dashboard.tsx | 3 +- .../[instance-id]/settings/device-pairing.tsx | 2 ++ .../agents/session-list-content.tsx | 33 +++++++++++++++++-- .../agents/session-list-header-actions.tsx | 8 +++-- .../components/agents/session-list-screen.tsx | 12 ++++++- .../kiloclaw/onboarding/provisioning-step.tsx | 14 ++++++-- 6 files changed, 62 insertions(+), 10 deletions(-) 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 ba0f7596d5..f351b5417a 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx @@ -196,7 +196,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" > 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 8769fec605..f737e1cea5 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 @@ -76,6 +76,8 @@ export default function DevicePairingScreen() { void handleRefresh(); }} className="p-2 active:opacity-70" + // 18px icon + p-2 = 34pt; slop brings the target to 44pt. + hitSlop={5} > diff --git a/apps/mobile/src/components/agents/session-list-content.tsx b/apps/mobile/src/components/agents/session-list-content.tsx index aa76795deb..5806a9e285 100644 --- a/apps/mobile/src/components/agents/session-list-content.tsx +++ b/apps/mobile/src/components/agents/session-list-content.tsx @@ -85,6 +85,10 @@ 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( () => ( @@ -109,14 +113,14 @@ export function AgentSessionListContent({ ) : null} - {isError ? ( + {showInlineError ? ( Could not refresh — showing the last saved list. ) : null} ), - [colors.mutedForeground, isError, isSearchPending, onSearchChange] + [colors.mutedForeground, showInlineError, isSearchPending, onSearchChange] ); const emptyStateAction = useMemo( @@ -158,6 +162,24 @@ export function AgentSessionListContent({ [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( () => new Map(storedSessions.map(s => [s.session_id, s.organization_id])), [storedSessions] @@ -272,6 +294,11 @@ export function AgentSessionListContent({ ); } + let emptyComponent = null; + if (hasActiveQuery) { + emptyComponent = isError ? queryErrorEmptyComponent : filteredEmptyComponent; + } + return ( @@ -280,7 +307,7 @@ export function AgentSessionListContent({ renderSectionHeader={renderSectionHeader} keyExtractor={keyExtractor} ListHeaderComponent={listHeader} - ListEmptyComponent={hasActiveQuery ? filteredEmptyComponent : null} + ListEmptyComponent={emptyComponent} ListFooterComponent={ isFetchingNextPage ? ( diff --git a/apps/mobile/src/components/agents/session-list-header-actions.tsx b/apps/mobile/src/components/agents/session-list-header-actions.tsx index 6648cdbd92..0bf251002e 100644 --- a/apps/mobile/src/components/agents/session-list-header-actions.tsx +++ b/apps/mobile/src/components/agents/session-list-header-actions.tsx @@ -25,8 +25,9 @@ export function SessionListHeaderActions({ {showNewSession ? ( { + if (!isSearching) { + return refetch(); + } + const [searchResult, listHadError] = await Promise.all([searchRefetch(), refetch()]); + return searchResult.isError || listHadError; + }, [isSearching, refetch, searchRefetch]); + const refetchRef = useRef(refetch); useEffect(() => { refetchRef.current = refetch; @@ -296,7 +306,7 @@ export function AgentSessionListScreen() { isSearchPending={isSearchPending} isError={contentIsError} isFetchingNextPage={isFetchingNextPage} - refetch={refetch} + refetch={handleRefetch} onRetry={handleRetry} onEndReached={handleEndReached} onSessionPress={navigateToSession} diff --git a/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx b/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx index a8978cb9da..3ab962dfc0 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx @@ -9,10 +9,12 @@ import { AlertTriangle } from 'lucide-react-native'; import { useEffect, useMemo, useState } from 'react'; import { View } from 'react-native'; import Animated, { + cancelAnimation, Easing, FadeIn, FadeOut, useAnimatedStyle, + useReducedMotion, useSharedValue, withRepeat, withTiming, @@ -96,9 +98,14 @@ export function ProvisioningStep({ const stageMessage = useMemo(() => provisioningStageMessage(state), [state]); // Gentle breathing pulse on the avatar tile — signals "actively working" - // without the spinner-in-the-middle-of-nowhere look. + // without the spinner-in-the-middle-of-nowhere look. Static tile when + // Reduce Motion is on, same as Skeleton/SpinningIcon. + const reducedMotion = useReducedMotion(); const pulse = useSharedValue(1); useEffect(() => { + if (reducedMotion) { + return undefined; + } pulse.value = withRepeat( withTiming(PULSE_PEAK, { duration: PULSE_DURATION_MS, @@ -107,7 +114,10 @@ export function ProvisioningStep({ -1, true ); - }, [pulse]); + return () => { + cancelAnimation(pulse); + }; + }, [pulse, reducedMotion]); const pulseStyle = useAnimatedStyle(() => ({ transform: [{ scale: pulse.value }], })); From 07e133408102924393efbef571cfce55bd3b2480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 01:27:04 +0200 Subject: [PATCH 135/166] fix: address second-round review findings on redeliver, sentry consent, session ops - sentry consent: the close+init chain promise never rejects (resolved via a gate in finally), so a failed transition can't poison later ones; the caller un-marks the applied consent state on failure so the next change re-attempts teardown. Effect extracted to useSentryConsentSync. - session ops: per-session promise chain serializes rename/delete so optimistic snapshots can't interleave and an older request can't overwrite a newer result; identical duplicate taps still dedupe - redeliver: DO requires exactly one eligible bot (zero bots no longer clears the flag and fakes success; multiple would double-deliver) and publishes message.redelivered BEFORE enqueueing the delivery attempt, so a fresh delivery failure can't be clobbered by a delayed clear event; the hooks-side retry now always invalidates after a rollback because a lost HTTP response can hide a committed redelivery - synced plugin copy of the kilo-chat event contract regenerated --- apps/mobile/src/app/_layout.tsx | 18 +---- .../src/lib/hooks/use-sentry-consent-sync.ts | 28 +++++++ .../src/lib/hooks/use-session-mutations.ts | 51 +++++++----- apps/mobile/src/lib/sentry-consent.ts | 19 ++++- packages/kilo-chat-hooks/src/use-messages.ts | 9 ++- .../src/__tests__/conversation-do.test.ts | 27 +++++++ services/kilo-chat/src/do/conversation-do.ts | 79 +++++++++++++------ services/kilo-chat/src/routes/handler.ts | 15 +--- .../plugins/kilo-chat/src/synced/events.ts | 6 ++ 9 files changed, 178 insertions(+), 74 deletions(-) create mode 100644 apps/mobile/src/lib/hooks/use-sentry-consent-sync.ts diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 4ec96928c5..9f674a3042 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -19,7 +19,7 @@ import { } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; import { StatusBar } from 'expo-status-bar'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { View } from 'react-native'; import { AppRootProviders } from '@/components/app-root-providers'; @@ -40,7 +40,8 @@ import { setupNotificationResponseHandler, } from '@/lib/notifications'; import { resolvePendingNotificationNavigation } from '@/lib/pending-notification-navigation'; -import { reinitSentryForConsent, sentryOptionsForConsent } from '@/lib/sentry-consent'; +import { sentryOptionsForConsent } from '@/lib/sentry-consent'; +import { useSentryConsentSync } from '@/lib/hooks/use-sentry-consent-sync'; const navigationIntegration = Sentry.reactNavigationIntegration({ enableTimeToInitialDisplay: !isRunningInExpoGo(), @@ -108,18 +109,7 @@ function RootLayoutNav() { } }, [fontsError]); - // Starts `false` because module scope above already ran initSentry(false). - const appliedSentryConsentRef = useRef(false); - - useEffect(() => { - const consented = consentChecked && !needsConsent; - if (appliedSentryConsentRef.current === consented) { - return; - } - - appliedSentryConsentRef.current = consented; - void reinitSentryForConsent(consented, initSentry); - }, [consentChecked, needsConsent]); + useSentryConsentSync(consentChecked && !needsConsent, initSentry); const fontsReady = fontsLoaded || fontsError !== null; const isLoading = authLoading || updateChecking || !fontsReady; 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 2423c2da63..be054c529d 100644 --- a/apps/mobile/src/lib/hooks/use-session-mutations.ts +++ b/apps/mobile/src/lib/hooks/use-session-mutations.ts @@ -19,9 +19,10 @@ const onError = (error: { message: string }) => { export function useSessionMutations() { const trpc = useTRPC(); const queryClient = useQueryClient(); - // A ref (not state) is enough — this only gates duplicate taps - // on the same row, it never needs to drive a re-render. + // Refs (not state) are enough — these only gate duplicate taps and order + // mutations per row, they never need to drive a re-render. const pendingSessionIds = useRef(new Set()); + const sessionOpChains = useRef(new Map>()); const listKey = trpc.cliSessionsV2.list.infiniteQueryKey(); const invalidateSessions = async () => { @@ -73,40 +74,48 @@ export function useSessionMutations() { }) ); - // Optimistic updates already make the row change land instantly, so a - // second tap before the first mutation settles would just be a duplicate - // in-flight request for the same operation — drop it instead of firing - // another. Keyed by operation + session so a delete issued while a rename - // settles (or vice versa) still goes through. - const withPendingGuard = (pendingKey: string, run: () => Promise) => { - if (pendingSessionIds.current.has(pendingKey)) { + // Two rules per session row: + // - identical duplicate taps (same op + args) are dropped while pending — + // optimistic updates make the first tap land instantly, a second is noise; + // - DISTINCT operations (a delete during a settling rename, a re-rename to + // a different title) run, but serialized through a per-session promise + // chain so their optimistic snapshots/rollbacks can't interleave and an + // older request can never overwrite a newer one's result. + const enqueueSessionOp = (sessionId: string, opKey: string, run: () => Promise) => { + if (pendingSessionIds.current.has(opKey)) { return; } - pendingSessionIds.current.add(pendingKey); - void (async () => { + pendingSessionIds.current.add(opKey); + const previous = sessionOpChains.current.get(sessionId); + let next: Promise | undefined = undefined; + next = (async () => { + try { + await previous; + } catch { + // The prior op already reported via its mutation's onError. + } try { await run(); } catch { // Already surfaced via the mutation's own onError (toast + rollback). - } finally { - pendingSessionIds.current.delete(pendingKey); + } + pendingSessionIds.current.delete(opKey); + if (sessionOpChains.current.get(sessionId) === next) { + sessionOpChains.current.delete(sessionId); } })(); + sessionOpChains.current.set(sessionId, next); }; return { deleteSession: (sessionId: string) => { - withPendingGuard(`delete:${sessionId}`, async () => { - const result = await deleteSessionMutation.mutateAsync({ session_id: sessionId }); - return result; + enqueueSessionOp(sessionId, `delete:${sessionId}`, async () => { + await deleteSessionMutation.mutateAsync({ session_id: sessionId }); }); }, renameSession: (sessionId: string, title: string) => { - // Title in the key: a re-rename to a different title is a new - // operation, only the identical duplicate gets dropped. - withPendingGuard(`rename:${sessionId}:${title}`, async () => { - const result = await renameSessionMutation.mutateAsync({ session_id: sessionId, title }); - return result; + enqueueSessionOp(sessionId, `rename:${sessionId}:${title}`, async () => { + await renameSessionMutation.mutateAsync({ session_id: sessionId, title }); }); }, }; diff --git a/apps/mobile/src/lib/sentry-consent.ts b/apps/mobile/src/lib/sentry-consent.ts index 4c14cafcfa..c21833c594 100644 --- a/apps/mobile/src/lib/sentry-consent.ts +++ b/apps/mobile/src/lib/sentry-consent.ts @@ -38,17 +38,28 @@ export function sentryOptionsForConsent(consented: boolean): SentryConsentOption // (it awaits closeNativeSdk, which uninstalls the native replay integration), // so every consent transition is close-then-init. Chained through `lifecycle` // so a fast accept → revoke can't interleave close and init. +// The chain promise never rejects (each transition resolves it in `finally`), +// so a failed transition 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 + init: (consented: boolean) => void, + onFailure?: () => void ): Promise { const previous = lifecycle; - lifecycle = (async () => { + const gate: { release?: () => void } = {}; + lifecycle = new Promise(resolve => { + gate.release = resolve; + }); + try { await previous; await Sentry.close(); init(consented); - })(); - await lifecycle; + } catch { + onFailure?.(); + } finally { + gate.release?.(); + } } diff --git a/packages/kilo-chat-hooks/src/use-messages.ts b/packages/kilo-chat-hooks/src/use-messages.ts index 3debd3d52e..16700a712f 100644 --- a/packages/kilo-chat-hooks/src/use-messages.ts +++ b/packages/kilo-chat-hooks/src/use-messages.ts @@ -754,13 +754,18 @@ export function useRedeliverMessage(client: KiloChatClient, conversationId: stri }, onError: (_err, _variables, context) => { if (!context) return; - const restored = restoreOptimisticMessage( + restoreOptimisticMessage( queryClient, context.queryKey, context.snapshot, context.optimisticMessage ); - if (!restored) invalidateMessages(queryClient, context.queryKey); + // Always reconcile (not just when restore fails): a lost HTTP response + // can mean the server actually committed the redelivery — its + // message.redelivered event may have already updated the row to the + // same value as our optimistic one, in which case the restore above + // resurrected a stale deliveryFailed with no later event to correct it. + invalidateMessages(queryClient, context.queryKey); }, }); } diff --git a/services/kilo-chat/src/__tests__/conversation-do.test.ts b/services/kilo-chat/src/__tests__/conversation-do.test.ts index 834598e054..da4540899c 100644 --- a/services/kilo-chat/src/__tests__/conversation-do.test.ts +++ b/services/kilo-chat/src/__tests__/conversation-do.test.ts @@ -1202,6 +1202,33 @@ describe('ConversationDO', () => { 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' }); diff --git a/services/kilo-chat/src/do/conversation-do.ts b/services/kilo-chat/src/do/conversation-do.ts index 1ca819b2e0..81a7bc509b 100644 --- a/services/kilo-chat/src/do/conversation-do.ts +++ b/services/kilo-chat/src/do/conversation-do.ts @@ -52,6 +52,7 @@ import { botMessageNotificationTextLength, sendConversationMessagePush, } from '../services/push-notifications'; +import { pushEventToHumanMembers } from '../services/event-push'; import migrations from '../../drizzle/conversation/migrations'; import { monotonicFactory } from 'ulid'; @@ -262,7 +263,7 @@ export type RedeliverMessageParams = { export type RedeliverMessageResult = | { ok: true; redelivered: boolean; memberContext: MemberContext } - | { ok: false; code: 'not_found' | 'forbidden'; error: string }; + | { ok: false; code: 'not_found' | 'forbidden' | 'conflict'; error: string }; export type InitAttachmentParams = { uploaderId: string; @@ -455,10 +456,11 @@ export class ConversationDO extends DurableObject { /** * Re-attempts bot delivery of an existing delivery-failed message. Clears - * the `delivery_failed` flag and 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`. + * 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(); @@ -490,6 +492,25 @@ export class ConversationDO extends DurableObject { return { ok: true, redelivered: false, memberContext }; } + // 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]!; + this.db .update(messages) .set({ delivery_failed: 0 }) @@ -518,26 +539,40 @@ export class ConversationDO extends DurableObject { } } - const sentAt = new Date().toISOString(); - for (const bot of activeMembers.filter(member => member.kind === 'bot')) { - await this.enqueueMessageWebhook( - { - 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 }), - }, - memberContext + // Publish the flag-clear event BEFORE the delivery attempt is enqueued: + // the bot acks receipt before processing and can asynchronously report a + // fresh message.delivery_failed — publishing after enqueue would let that + // failure land first and then be wrongly cleared by a delayed + // message.redelivered. + if (memberContext.sandboxId) { + await pushEventToHumanMembers( + this.env, + conversationId, + memberContext.sandboxId, + memberContext.humanMemberIds, + 'message.redelivered', + { messageId: row.id } ); } + const sentAt = new Date().toISOString(); + await this.enqueueMessageWebhook( + { + 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 }), + }, + memberContext + ); + return { ok: true, redelivered: true, memberContext }; } diff --git a/services/kilo-chat/src/routes/handler.ts b/services/kilo-chat/src/routes/handler.ts index 8e47ab5d20..9151fe2e31 100644 --- a/services/kilo-chat/src/routes/handler.ts +++ b/services/kilo-chat/src/routes/handler.ts @@ -264,20 +264,13 @@ export async function handleRedeliverMessage(c: HonoCtx) { 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); } - if (result.redelivered && result.memberContext.sandboxId) { - const pushPromise = pushEventToHumanMembers( - c.env, - convId.data, - result.memberContext.sandboxId, - result.memberContext.humanMemberIds, - 'message.redelivered', - { messageId: msgId.data } - ); - c.executionCtx.waitUntil(pushPromise); - } + // 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); } 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 Date: Sun, 12 Jul 2026 01:27:14 +0200 Subject: [PATCH 136/166] fix(mobile): reasoning settings sheet stays navigable for VoiceOver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backdrop and content pressables default to accessible=true, collapsing the whole sheet into one VoiceOver node with the Switch unreachable — same bug class as the platform-filter sheet, same accessible={false} fix. --- .../src/components/agents/reasoning-settings-modal.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/components/agents/reasoning-settings-modal.tsx b/apps/mobile/src/components/agents/reasoning-settings-modal.tsx index f0a85bbaaa..0a49070c37 100644 --- a/apps/mobile/src/components/agents/reasoning-settings-modal.tsx +++ b/apps/mobile/src/components/agents/reasoning-settings-modal.tsx @@ -21,11 +21,16 @@ export function ReasoningSettingsModal({ { event.stopPropagation(); }} From 9bb8762e30921c0333d13d500d9f31872d7bfaa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 01:33:22 +0200 Subject: [PATCH 137/166] fix(mobile): kiloclaw copy sentence case and pairing-refresh a11y label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 'Copy Command' / 'Destroy Instance' → sentence case per the copy convention - device-pairing header refresh icon gets a role + label for screen readers --- .../(app)/kiloclaw/[instance-id]/settings/device-pairing.tsx | 2 ++ .../src/app/(app)/kiloclaw/[instance-id]/settings/google.tsx | 2 +- apps/mobile/src/components/kiloclaw/dashboard-parts.tsx | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) 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 f737e1cea5..a72a5a64bc 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 @@ -78,6 +78,8 @@ export default function DevicePairingScreen() { 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" > 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 cd4e2455fd..eda70e3221 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 @@ -223,7 +223,7 @@ export default function GoogleScreen() { void handleCopy(); }} > - {copied ? 'Copied!' : 'Copy Command'} + {copied ? 'Copied!' : 'Copy command'} )} diff --git a/apps/mobile/src/components/kiloclaw/dashboard-parts.tsx b/apps/mobile/src/components/kiloclaw/dashboard-parts.tsx index 5bb1faed7f..252de778a0 100644 --- a/apps/mobile/src/components/kiloclaw/dashboard-parts.tsx +++ b/apps/mobile/src/components/kiloclaw/dashboard-parts.tsx @@ -218,7 +218,7 @@ export function DangerZone({ pending, onDestroy }: Readonly) { )} - {pending ? 'Destroying…' : 'Destroy Instance'} + {pending ? 'Destroying…' : 'Destroy instance'} From 5d53f64d7a18147b2d5e0d941aa4842a3676d097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 02:26:11 +0200 Subject: [PATCH 138/166] fix(mobile): personal security-agent scope survives an org-list outage Even a disabled organizations.list observer surfaces the shared cache entry's error state, so an org-list failure rendered the personal scope entry as a full-screen error although every personal securityAgent call succeeds. The role query now masks query state entirely for the personal scope, where the org list is irrelevant. --- .../src/lib/hooks/use-security-agent.ts | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/lib/hooks/use-security-agent.ts b/apps/mobile/src/lib/hooks/use-security-agent.ts index 329a96ceca..0a440beb80 100644 --- a/apps/mobile/src/lib/hooks/use-security-agent.ts +++ b/apps/mobile/src/lib/hooks/use-security-agent.ts @@ -103,19 +103,31 @@ export function useSecurityAgentLastSyncTime(scope: string, repoFullName?: strin // 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). // -// A disabled query never conflates with a failed one here — react-query -// reports `isLoading`/`isError` as `false` for `enabled: false` — but 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. Consumers that must tell those apart use -// `useSecurityAgentOrgRoleQuery`/`useSecurityAgentCapability` below; existing -// boolean-only callers are unchanged. +// 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. Consumers that must tell those +// apart use `useSecurityAgentOrgRoleQuery`/`useSecurityAgentCapability` +// below; existing boolean-only callers are unchanged. function useSecurityAgentOrgRoleQuery(scope: string) { const trpc = useTRPC(); + const isPersonal = isPersonalSecurityScope(scope); const query = useQuery({ ...trpc.organizations.list.queryOptions(), - enabled: !isPersonalSecurityScope(scope), + 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, From cd66540318e14daec5a5acbb10c97b63ac81f3ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 10:17:48 +0200 Subject: [PATCH 139/166] fix(mobile): formSheet picker headers render; device-auth timeout scoped to the POST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SheetHeader was invisible on formSheet pickers: react-native-screens lays out a formSheet's scroll view natively and only honors a header when [header, scroll view] are the screen content's direct children — the wrapping View made it pin the list over the header. PickerSheet drops the wrapper (fragment) and marks the header collapsable={false} so view flattening can't recreate the bug; mode-picker migrates to PickerSheet. Verified on-device: mode + model pickers now show title + Done. - device-auth: the 15s start timeout now clears as soon as the POST resolves. start() stays suspended on the auth browser await for as long as the sheet is open, so the timer previously fired mid-sign-in, stomped the live pending/idle state with a bogus error, and left a zombie poll running behind it. --- .../src/app/(app)/agent-chat/mode-picker.tsx | 46 +++++++++---------- apps/mobile/src/components/picker-sheet.tsx | 10 ++-- apps/mobile/src/components/sheet-header.tsx | 5 +- apps/mobile/src/lib/auth/use-device-auth.ts | 5 ++ 4 files changed, 39 insertions(+), 27 deletions(-) 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 0b1df3f9c7..7c45835166 100644 --- a/apps/mobile/src/app/(app)/agent-chat/mode-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/mode-picker.tsx @@ -7,7 +7,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 { EmptyState } from '@/components/empty-state'; -import { SheetHeader } from '@/components/sheet-header'; +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'; @@ -35,19 +35,20 @@ export default function ModePickerScreen() { if (!bridge) { return ( - - { - router.back(); - }} - /> - - + { + router.back(); + }} + scrollable={false} + fallback={ + + } + /> ); } @@ -77,21 +78,20 @@ export default function ModePickerScreen() { } return ( - - { - router.back(); - }} - /> + { + router.back(); + }} + scrollable={false} + > item.value} renderItem={renderItem} - contentInsetAdjustmentBehavior="automatic" ItemSeparatorComponent={() => } /> - + ); } diff --git a/apps/mobile/src/components/picker-sheet.tsx b/apps/mobile/src/components/picker-sheet.tsx index 89f1ee0927..725e74b49c 100644 --- a/apps/mobile/src/components/picker-sheet.tsx +++ b/apps/mobile/src/components/picker-sheet.tsx @@ -1,5 +1,5 @@ import { type ReactNode } from 'react'; -import { ScrollView, View } from 'react-native'; +import { ScrollView } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { SheetHeader } from '@/components/sheet-header'; @@ -26,14 +26,18 @@ export function PickerSheet({ const { bottom } = useSafeAreaInsets(); const body = fallback ?? 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/sheet-header.tsx b/apps/mobile/src/components/sheet-header.tsx index f4db9ac8c8..8dfb69f7b9 100644 --- a/apps/mobile/src/components/sheet-header.tsx +++ b/apps/mobile/src/components/sheet-header.tsx @@ -4,7 +4,10 @@ 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} diff --git a/apps/mobile/src/lib/auth/use-device-auth.ts b/apps/mobile/src/lib/auth/use-device-auth.ts index 878b0b36e4..6cac5eb3dc 100644 --- a/apps/mobile/src/lib/auth/use-device-auth.ts +++ b/apps/mobile/src/lib/auth/use-device-auth.ts @@ -194,6 +194,11 @@ export function useDeviceAuth(): DeviceAuthResult { 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. From 3f0f446328d6f60d700df9d9737306b707a55070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 10:23:19 +0200 Subject: [PATCH 140/166] fix(kilo-chat): drop redundant non-null assertion on length-checked bot The bots.length !== 1 guard already narrows bots[0] to ActiveMemberRow; the root oxlint config forbids the non-null assertion and flags it as unnecessary. --- services/kilo-chat/src/do/conversation-do.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/kilo-chat/src/do/conversation-do.ts b/services/kilo-chat/src/do/conversation-do.ts index 81a7bc509b..21e5990433 100644 --- a/services/kilo-chat/src/do/conversation-do.ts +++ b/services/kilo-chat/src/do/conversation-do.ts @@ -509,7 +509,7 @@ export class ConversationDO extends DurableObject { : 'Redelivery is ambiguous with multiple bots', }; } - const bot = bots[0]!; + const bot = bots[0]; this.db .update(messages) From 9f2cdad2c09ee42b804d8c25ab3f2959265f8642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 10:49:36 +0200 Subject: [PATCH 141/166] =?UTF-8?q?fix:=20third-round=20review=20=E2=80=94?= =?UTF-8?q?=20rename=20latest-intent,=20redeliver=20ordering=20+=20event-d?= =?UTF-8?q?riven=20clear?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - session mutations: dedup only an ADJACENT identical op (repeat of the chain tail) instead of any op in a global set, so rename A -> B -> A runs the final A and the user's latest intent wins; double-taps still coalesce - kilo-chat redeliver (DO): reserve the webhook-chain slot synchronously with no external await first, so a concurrent request can't slot a later message ahead of the redelivery or run delivery out of order; the redelivered event is pushed inside the serialized task, strictly before deliverToBot - kilo-chat redeliver (client): drop the optimistic clear + guarded restore + invalidate; the server's message.redelivered event is the single source of truth for the flag, which removes both the rollback ambiguity on an unclear HTTP error and the refetch race that could overwrite a newer delivery failure with a stale pre-failure snapshot --- .../src/lib/hooks/use-session-mutations.ts | 25 +++--- packages/kilo-chat-hooks/src/use-messages.ts | 44 +++-------- services/kilo-chat/src/do/conversation-do.ts | 78 ++++++++++--------- 3 files changed, 65 insertions(+), 82 deletions(-) diff --git a/apps/mobile/src/lib/hooks/use-session-mutations.ts b/apps/mobile/src/lib/hooks/use-session-mutations.ts index be054c529d..618284485b 100644 --- a/apps/mobile/src/lib/hooks/use-session-mutations.ts +++ b/apps/mobile/src/lib/hooks/use-session-mutations.ts @@ -21,8 +21,8 @@ export function useSessionMutations() { const queryClient = useQueryClient(); // Refs (not state) are enough — these only gate duplicate taps and order // mutations per row, they never need to drive a re-render. - const pendingSessionIds = useRef(new Set()); const sessionOpChains = useRef(new Map>()); + const sessionOpTailKeys = useRef(new Map()); const listKey = trpc.cliSessionsV2.list.infiniteQueryKey(); const invalidateSessions = async () => { @@ -74,18 +74,19 @@ export function useSessionMutations() { }) ); - // Two rules per session row: - // - identical duplicate taps (same op + args) are dropped while pending — - // optimistic updates make the first tap land instantly, a second is noise; - // - DISTINCT operations (a delete during a settling rename, a re-rename to - // a different title) run, but serialized through a per-session promise - // chain so their optimistic snapshots/rollbacks can't interleave and an - // older request can never overwrite a newer one's result. + // Per session row: DISTINCT operations (a delete during a settling rename, + // a re-rename to a different title) run serialized through a per-session + // promise chain so their optimistic snapshots/rollbacks can't interleave + // and an older request can never overwrite a newer one's result. Only an + // ADJACENT identical op is dropped — a repeat of whatever is currently the + // tail of the chain (the double-tap case). A non-adjacent repeat (rename + // A → B → A) must still run so the user's latest intent wins; keying dedup + // off the tail rather than a global set is what lets A run last. const enqueueSessionOp = (sessionId: string, opKey: string, run: () => Promise) => { - if (pendingSessionIds.current.has(opKey)) { + if (sessionOpTailKeys.current.get(sessionId) === opKey) { return; } - pendingSessionIds.current.add(opKey); + sessionOpTailKeys.current.set(sessionId, opKey); const previous = sessionOpChains.current.get(sessionId); let next: Promise | undefined = undefined; next = (async () => { @@ -99,9 +100,11 @@ export function useSessionMutations() { } catch { // Already surfaced via the mutation's own onError (toast + rollback). } - pendingSessionIds.current.delete(opKey); + // Only the last op in the chain clears the per-session state, so a fresh + // tap after everything settles is never mistaken for an adjacent repeat. if (sessionOpChains.current.get(sessionId) === next) { sessionOpChains.current.delete(sessionId); + sessionOpTailKeys.current.delete(sessionId); } })(); sessionOpChains.current.set(sessionId, next); diff --git a/packages/kilo-chat-hooks/src/use-messages.ts b/packages/kilo-chat-hooks/src/use-messages.ts index 16700a712f..679e8ddc42 100644 --- a/packages/kilo-chat-hooks/src/use-messages.ts +++ b/packages/kilo-chat-hooks/src/use-messages.ts @@ -728,45 +728,21 @@ 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), so the cache - * update is just an optimistic clear of `deliveryFailed`. If the retry fails - * permanently again the server pushes a fresh `message.delivery_failed` event. + * re-attempts delivery of the same message row (no new message) and is the + * single source of truth for the flag: it pushes `message.redelivered` + * (clears `deliveryFailed`, handled by useMessageCacheUpdater) right before + * the attempt, and `message.delivery_failed` again if the retry fails. + * + * Deliberately NOT optimistic: an optimistic clear here would race those + * events (a rejected redelivery can't be told apart from a committed one on + * an ambiguous HTTP error, and a reconciling refetch can capture a stale + * pre-failure snapshot). Letting the event drive the flag keeps a single + * authority and costs only one event round-trip before the indicator clears. */ export function useRedeliverMessage(client: KiloChatClient, conversationId: string | null) { - const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ messageId }: { messageId: string }) => client.redeliverMessage(conversationId ?? '', messageId), - onMutate: async variables => { - if (!conversationId) return; - const queryKey = messagesKey(conversationId); - await queryClient.cancelQueries({ queryKey }); - const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); - const optimisticMessage = snapshot ? { ...snapshot, deliveryFailed: false } : undefined; - queryClient.setQueryData(queryKey, old => { - if (!old) return old; - return updateMessageInPages(old, variables.messageId, msg => ({ - ...msg, - deliveryFailed: false, - })); - }); - return { queryKey, snapshot, optimisticMessage }; - }, - onError: (_err, _variables, context) => { - if (!context) return; - restoreOptimisticMessage( - queryClient, - context.queryKey, - context.snapshot, - context.optimisticMessage - ); - // Always reconcile (not just when restore fails): a lost HTTP response - // can mean the server actually committed the redelivery — its - // message.redelivered event may have already updated the row to the - // same value as our optimistic one, in which case the restore above - // resurrected a stale deliveryFailed with no later event to correct it. - invalidateMessages(queryClient, context.queryKey); - }, }); } diff --git a/services/kilo-chat/src/do/conversation-do.ts b/services/kilo-chat/src/do/conversation-do.ts index 21e5990433..b0d8f7ead1 100644 --- a/services/kilo-chat/src/do/conversation-do.ts +++ b/services/kilo-chat/src/do/conversation-do.ts @@ -511,11 +511,10 @@ export class ConversationDO extends DurableObject { } const bot = bots[0]; - this.db - .update(messages) - .set({ delivery_failed: 0 }) - .where(eq(messages.id, params.messageId)) - .run(); + // 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); @@ -539,39 +538,44 @@ export class ConversationDO extends DurableObject { } } - // Publish the flag-clear event BEFORE the delivery attempt is enqueued: - // the bot acks receipt before processing and can asynchronously report a - // fresh message.delivery_failed — publishing after enqueue would let that - // failure land first and then be wrongly cleared by a delayed - // message.redelivered. - if (memberContext.sandboxId) { - await pushEventToHumanMembers( - this.env, - conversationId, - memberContext.sandboxId, - memberContext.humanMemberIds, - 'message.redelivered', - { messageId: row.id } - ); - } - const sentAt = new Date().toISOString(); - await this.enqueueMessageWebhook( - { - 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 }), - }, - memberContext - ); + 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, memberContext }; } From f2850dc385ec5cce5084f2cd5df7a86c28aab9da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 11:02:13 +0200 Subject: [PATCH 142/166] fix(kilo-chat): clear delivery-failed on redeliver success, not only via event pushEventToHumanMembers is best-effort, so a requesting client whose socket dropped would miss message.redelivered and keep showing Retry indefinitely even though the server cleared the flag and retried. Apply the clear on HTTP success as a targeted single-message cache write (not optimistic, not a refetch); a strictly-later message.delivery_failed event still restores failure if the retry ultimately fails. --- packages/kilo-chat-hooks/src/use-messages.ts | 34 ++++++++++++++------ 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/kilo-chat-hooks/src/use-messages.ts b/packages/kilo-chat-hooks/src/use-messages.ts index 679e8ddc42..2921baa003 100644 --- a/packages/kilo-chat-hooks/src/use-messages.ts +++ b/packages/kilo-chat-hooks/src/use-messages.ts @@ -728,21 +728,37 @@ 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 is the - * single source of truth for the flag: it pushes `message.redelivered` - * (clears `deliveryFailed`, handled by useMessageCacheUpdater) right before - * the attempt, and `message.delivery_failed` again if the retry fails. + * 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. * - * Deliberately NOT optimistic: an optimistic clear here would race those - * events (a rejected redelivery can't be told apart from a committed one on - * an ambiguous HTTP error, and a reconciling refetch can capture a stale - * pre-failure snapshot). Letting the event drive the flag keeps a single - * authority and costs only one event round-trip before the indicator clears. + * 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 + ); + }, }); } From a33bb8452b5538f8a0b758ab0f76962128678499 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 12:30:39 +0200 Subject: [PATCH 143/166] simplify: remove dead flexibility from mobile shared UI primitives - ScreenHeader: drop unused backLabel/fallbackHref props (zero call sites) - QueryError: unify message/description into one prop (message), delegate its shell to EmptyState with a bubble-chrome override instead of duplicating layout; keep permission/not-found variants (still used by review-detail-screen) - PickerSheet: replace the generic `fallback` prop with a single `expired` boolean, folding the repeated "Options expired" EmptyState into the component itself - StatusDot: drop the always-true `glow` prop - route-params: collapse parseParam's two overloads into one generic signature with a default type param --- .../(1_kiloclaw)/chat/instance-picker.tsx | 2 +- .../src/app/(app)/agent-chat/[session-id].tsx | 2 +- .../src/app/(app)/agent-chat/mode-picker.tsx | 11 +--- apps/mobile/src/app/(app)/agent-chat/new.tsx | 4 +- .../src/app/(app)/agent-chat/repo-picker.tsx | 13 +---- .../agents/model-picker-content.tsx | 15 +---- .../agents/session-detail-content.tsx | 2 +- .../code-reviewer/manual-review-screen.tsx | 2 +- .../code-reviewer/scope-list-screen.tsx | 2 +- apps/mobile/src/components/empty-state.tsx | 17 +++++- .../components/kiloclaw/dashboard-parts.tsx | 2 +- .../src/components/kiloclaw/instance-card.tsx | 2 +- .../src/components/kiloclaw/status-badge.tsx | 2 +- .../organization/organization-boundary.tsx | 2 +- apps/mobile/src/components/picker-sheet.tsx | 18 ++++-- apps/mobile/src/components/profile-screen.tsx | 2 +- apps/mobile/src/components/query-error.tsx | 55 ++++++++----------- apps/mobile/src/components/screen-header.tsx | 18 ++---- apps/mobile/src/components/ui/session-row.tsx | 2 +- apps/mobile/src/components/ui/status-dot.tsx | 8 +-- apps/mobile/src/lib/route-params.ts | 15 ++--- 21 files changed, 79 insertions(+), 117 deletions(-) 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 ae95239800..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 @@ -88,7 +88,7 @@ export default function InstancePickerScreen() { {instancesQuery.isError ? ( { void instancesQuery.refetch(); }} diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx index 21a0365125..42dcb7e057 100644 --- a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx +++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx @@ -55,7 +55,7 @@ export default function SessionDetailScreen() { placement="top" className="px-0 pt-0" title="Could not load session" - description="Failed to load session details" + message="Failed to load session details" onRetry={() => 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 7c45835166..00d5bc2111 100644 --- a/apps/mobile/src/app/(app)/agent-chat/mode-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/mode-picker.tsx @@ -1,12 +1,11 @@ import * as Haptics from 'expo-haptics'; import { useRouter } from 'expo-router'; -import { Check, Info } from 'lucide-react-native'; +import { Check } from 'lucide-react-native'; import { useEffect, useState } from 'react'; 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 { 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'; @@ -41,13 +40,7 @@ export default function ModePickerScreen() { router.back(); }} scrollable={false} - fallback={ - - } + expired /> ); } diff --git a/apps/mobile/src/app/(app)/agent-chat/new.tsx b/apps/mobile/src/app/(app)/agent-chat/new.tsx index c7d39d1e4a..7d09a3505b 100644 --- a/apps/mobile/src/app/(app)/agent-chat/new.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/new.tsx @@ -360,7 +360,7 @@ export default function NewSessionScreen() { placement="top" variant="server" title="Couldn't load models" - description="Check your connection and try again." + message="Check your connection and try again." onRetry={() => void refetchModels()} className="border-t border-border py-4" /> @@ -386,7 +386,7 @@ export default function NewSessionScreen() { placement="top" variant="server" title="Couldn't load repositories" - description="Check your connection and try again." + message="Check your connection and try again." onRetry={() => void refetchRepos()} isRetrying={isRefetchingRepos} /> 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 1b0bf3acca..79b1e74421 100644 --- a/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx @@ -57,18 +57,7 @@ export default function RepoPickerScreen() { if (!bridge) { return ( - - } - /> + ); } diff --git a/apps/mobile/src/components/agents/model-picker-content.tsx b/apps/mobile/src/components/agents/model-picker-content.tsx index f19fcb7376..4689540a6e 100644 --- a/apps/mobile/src/components/agents/model-picker-content.tsx +++ b/apps/mobile/src/components/agents/model-picker-content.tsx @@ -135,20 +135,7 @@ export function ModelPickerContent() { ); if (!bridge) { - return ( - - } - /> - ); + return ; } return ( diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index a992805755..6ba48c7664 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -462,7 +462,7 @@ export function SessionDetailContent({ placement="top" className="px-0 pt-0" title="Couldn't load this session" - description={error} + message={error} onRetry={() => { void manager.switchSession(sessionId); }} 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 773458bd3b..c30a4775d6 100644 --- a/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx @@ -115,7 +115,7 @@ export function ManualReviewScreen({ scope }: Readonly<{ scope: string }>) { { void githubStatus.refetch(); void gitlabStatus.refetch(); 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 098b78b6ec..16656d09a3 100644 --- a/apps/mobile/src/components/code-reviewer/scope-list-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/scope-list-screen.tsx @@ -34,7 +34,7 @@ export function ScopeListScreen() { void refetch()} diff --git a/apps/mobile/src/components/empty-state.tsx b/apps/mobile/src/components/empty-state.tsx index 000fd56e44..b519adde42 100644 --- a/apps/mobile/src/components/empty-state.tsx +++ b/apps/mobile/src/components/empty-state.tsx @@ -6,6 +6,8 @@ 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; @@ -13,6 +15,10 @@ type EmptyStateProps = { 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; }; export function EmptyState({ @@ -22,6 +28,9 @@ export function EmptyState({ className, action, placement = 'center', + iconContainerClassName = DEFAULT_ICON_CONTAINER_CLASS, + iconSize = 24, + iconStrokeWidth = 1.5, }: Readonly) { const colors = useThemeColors(); @@ -33,11 +42,13 @@ export function EmptyState({ className )} > - - + + - {title} + + {title} + {description} diff --git a/apps/mobile/src/components/kiloclaw/dashboard-parts.tsx b/apps/mobile/src/components/kiloclaw/dashboard-parts.tsx index 252de778a0..49401507bd 100644 --- a/apps/mobile/src/components/kiloclaw/dashboard-parts.tsx +++ b/apps/mobile/src/components/kiloclaw/dashboard-parts.tsx @@ -53,7 +53,7 @@ export function DashboardHero({ name, status, uptime }: Readonly - + - + {label} diff --git a/apps/mobile/src/components/kiloclaw/status-badge.tsx b/apps/mobile/src/components/kiloclaw/status-badge.tsx index 136acb4d0d..b80659c923 100644 --- a/apps/mobile/src/components/kiloclaw/status-badge.tsx +++ b/apps/mobile/src/components/kiloclaw/status-badge.tsx @@ -61,7 +61,7 @@ export function StatusBadge({ return ( - + {label} diff --git a/apps/mobile/src/components/organization/organization-boundary.tsx b/apps/mobile/src/components/organization/organization-boundary.tsx index 738fb4f738..c534cf4aaf 100644 --- a/apps/mobile/src/components/organization/organization-boundary.tsx +++ b/apps/mobile/src/components/organization/organization-boundary.tsx @@ -57,7 +57,7 @@ export function OrganizationBoundary({ return ( void refetch() : undefined} isRetrying={isFetching} /> diff --git a/apps/mobile/src/components/picker-sheet.tsx b/apps/mobile/src/components/picker-sheet.tsx index 725e74b49c..b50c2b0243 100644 --- a/apps/mobile/src/components/picker-sheet.tsx +++ b/apps/mobile/src/components/picker-sheet.tsx @@ -1,21 +1,23 @@ +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, - fallback, + expired = false, scrollable = true, }: { title: string; onDone: () => void; children?: ReactNode; - /** Rendered instead of children when the caller's data source is gone. */ - fallback?: 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 @@ -24,7 +26,15 @@ export function PickerSheet({ scrollable?: boolean; }) { const { bottom } = useSafeAreaInsets(); - const body = fallback ?? children; + 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 diff --git a/apps/mobile/src/components/profile-screen.tsx b/apps/mobile/src/components/profile-screen.tsx index bd1ecc80c2..077628bc4d 100644 --- a/apps/mobile/src/components/profile-screen.tsx +++ b/apps/mobile/src/components/profile-screen.tsx @@ -176,7 +176,7 @@ export function ProfileScreen() { variant="server" placement="top" title="Could not load organization" - description="Organization and agent settings are unavailable until this loads." + message="Organization and agent settings are unavailable until this loads." onRetry={() => void refetchOrganizations()} isRetrying={organizationsFetching} /> diff --git a/apps/mobile/src/components/query-error.tsx b/apps/mobile/src/components/query-error.tsx index 7dea7f4302..99a3ea86df 100644 --- a/apps/mobile/src/components/query-error.tsx +++ b/apps/mobile/src/components/query-error.tsx @@ -6,12 +6,10 @@ import { ServerCrash, WifiOff, } 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'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { cn } from '@/lib/utils'; type QueryErrorVariant = 'neutral' | 'offline' | 'permission' | 'not-found' | 'server'; @@ -49,9 +47,7 @@ const VARIANT_META: Record< type QueryErrorProps = { variant?: QueryErrorVariant; title?: string; - /** Same as `description`, kept for existing call sites. New call sites should use `description` instead. */ message?: string; - description?: string; onRetry?: () => void; isRetrying?: boolean; className?: string; @@ -62,40 +58,35 @@ export function QueryError({ variant = 'offline', title, message, - description, onRetry, isRetrying = false, className, placement = 'center', }: Readonly) { - const colors = useThemeColors(); const meta = VARIANT_META[variant]; - const Icon = meta.icon; return ( - - - - - - - {title ?? meta.title} - - - {description ?? message ?? meta.description} - - - {onRetry && ( - - )} - + + Retry + + ) + } + /> ); } diff --git a/apps/mobile/src/components/screen-header.tsx b/apps/mobile/src/components/screen-header.tsx index 869ee61b07..0c8594c368 100644 --- a/apps/mobile/src/components/screen-header.tsx +++ b/apps/mobile/src/components/screen-header.tsx @@ -1,4 +1,4 @@ -import { type Href, useRouter } from 'expo-router'; +import { useRouter } from 'expo-router'; import { ChevronDown, ChevronLeft } from 'lucide-react-native'; import { Platform, Pressable, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -21,10 +21,6 @@ type ScreenHeaderProps = { onBack?: () => void; onTitlePress?: () => void; backIcon?: 'back' | 'close'; - /** Accessibility label for the back button. Defaults to "Close" when `backIcon` resolves to `close`, otherwise "Go back". */ - backLabel?: string; - /** Where to navigate when there's no back history (direct-entry/deep-link screens). Renders the back button and calls `router.replace(fallbackHref)` instead of `router.back()`. */ - fallbackHref?: Href; /** Extra classes on the outer header container. Overrides the default `px-4` for screens that need a different horizontal inset. */ className?: string; }; @@ -39,15 +35,12 @@ export function ScreenHeader({ onBack, onTitlePress, backIcon, - backLabel, - fallbackHref, className, }: Readonly) { const insets = useSafeAreaInsets(); const router = useRouter(); const colors = useThemeColors(); - const routerCanGoBack = router.canGoBack(); - const canGoBack = showBackButton ?? (routerCanGoBack || fallbackHref !== undefined); + const canGoBack = showBackButton ?? router.canGoBack(); // iOS modals are presented as cards already inset from the status bar const paddingTop = modal && Platform.OS === 'ios' ? 32 : insets.top + 8; @@ -55,7 +48,6 @@ export function ScreenHeader({ // When `backIcon` isn't specified, fall back to the historical behaviour // where iOS modals get a ChevronDown and everything else gets a ChevronLeft. const resolvedBackIcon = backIcon ?? (modal && Platform.OS === 'ios' ? 'close' : 'back'); - const resolvedBackLabel = backLabel ?? (resolvedBackIcon === 'close' ? 'Close' : 'Go back'); const titleClass = size === 'large' @@ -94,15 +86,13 @@ export function ScreenHeader({ onPress={() => { if (onBack) { onBack(); - } else if (routerCanGoBack) { + } else { router.back(); - } else if (fallbackHref) { - router.replace(fallbackHref); } }} hitSlop={12} accessibilityRole="button" - accessibilityLabel={resolvedBackLabel} + accessibilityLabel={resolvedBackIcon === 'close' ? 'Close' : 'Go back'} className="-ml-1 mr-1 active:opacity-70" > {resolvedBackIcon === 'close' ? ( 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/status-dot.tsx b/apps/mobile/src/components/ui/status-dot.tsx index 5862f12b91..87b2cab109 100644 --- a/apps/mobile/src/components/ui/status-dot.tsx +++ b/apps/mobile/src/components/ui/status-dot.tsx @@ -7,7 +7,6 @@ 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 the @@ -21,14 +20,11 @@ const TONE: Record = { }; /** - * 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 ( ( +export function parseParam( value: string | string[] | undefined, - allowed: readonly T[] -): T | null; -export function parseParam( - value: string | string[] | undefined, - allowed?: readonly string[] -): string | null { + allowed?: readonly T[] +): T | null { if (typeof value !== 'string' || value.length === 0) { return null; } - if (allowed && !allowed.includes(value)) { + if (allowed && !allowed.includes(value as T)) { return null; } - return value; + return value as T; } From aa63394f25071a08b4fdef11e6fef54a3600678b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 12:36:11 +0200 Subject: [PATCH 144/166] fix(mobile): scope EmptyState header a11y role to QueryError titles only Pre-existing EmptyState call sites keep their previous screen-reader behavior; only QueryError (which had accessibilityRole='header' before delegating to EmptyState) opts in via titleAccessibilityRole. --- apps/mobile/src/components/empty-state.tsx | 5 ++++- apps/mobile/src/components/query-error.tsx | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/components/empty-state.tsx b/apps/mobile/src/components/empty-state.tsx index b519adde42..696921372d 100644 --- a/apps/mobile/src/components/empty-state.tsx +++ b/apps/mobile/src/components/empty-state.tsx @@ -19,6 +19,8 @@ type EmptyStateProps = { 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({ @@ -31,6 +33,7 @@ export function EmptyState({ iconContainerClassName = DEFAULT_ICON_CONTAINER_CLASS, iconSize = 24, iconStrokeWidth = 1.5, + titleAccessibilityRole, }: Readonly) { const colors = useThemeColors(); @@ -46,7 +49,7 @@ export function EmptyState({ - + {title} diff --git a/apps/mobile/src/components/query-error.tsx b/apps/mobile/src/components/query-error.tsx index 99a3ea86df..f1f8aee651 100644 --- a/apps/mobile/src/components/query-error.tsx +++ b/apps/mobile/src/components/query-error.tsx @@ -75,6 +75,7 @@ export function QueryError({ iconContainerClassName="rounded-full bg-muted p-4" iconSize={32} iconStrokeWidth={2} + titleAccessibilityRole="header" action={ onRetry && ( - - - ); -} diff --git a/apps/mobile/src/components/rename-modal.tsx b/apps/mobile/src/components/rename-modal.tsx index 510bf7cf28..eb994310b4 100644 --- a/apps/mobile/src/components/rename-modal.tsx +++ b/apps/mobile/src/components/rename-modal.tsx @@ -12,6 +12,7 @@ type RenameModalProps = { initialValue: string; onSave: (name: string) => Promise; onClose: () => void; + maxLength?: number; }; // Mount this component only while the modal should be open (e.g. `{visible && }`) @@ -22,6 +23,7 @@ export function RenameModal({ initialValue, onSave, onClose, + maxLength = 50, }: Readonly) { const colors = useThemeColors(); const nameRef = useRef(initialValue); @@ -91,7 +93,7 @@ export function RenameModal({ setCanSave(trimmed.length > 0 && trimmed !== initialValue); }} autoFocus={Platform.OS !== 'android'} - maxLength={50} + maxLength={maxLength} editable={!pending} accessibilityState={{ disabled: pending }} /> 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/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/packages/kilo-chat-hooks/src/use-messages.test.ts b/packages/kilo-chat-hooks/src/use-messages.test.ts index 3db38a854b..d7ac9f8928 100644 --- a/packages/kilo-chat-hooks/src/use-messages.test.ts +++ b/packages/kilo-chat-hooks/src/use-messages.test.ts @@ -616,55 +616,6 @@ describe('send message cache settlement', () => { expect(queryClient.getQueryState(queryKey)?.isInvalidated).toBe(true); }); - it('leaves exactly one message when a retried send settles after the original failed row', () => { - const queryClient = new QueryClient(); - const queryKey = messagesKey('01KQK8Y0000000000000000003'); - const failedOriginal = message({ - id: '01KQK8Y2222222222222222224', - senderId: 'user-current', - content: textContent('never delivered'), - deliveryFailed: true, - }); - queryClient.setQueryData(queryKey, { - pageParams: [undefined], - pages: [messagePage([failedOriginal])], - }); - - const pendingId = 'pending-01KQK8Y1111111111111111113'; - const retryOptimisticMessage = message({ - id: pendingId, - senderId: 'user-current', - content: textContent('never delivered'), - }); - const context = applyOptimisticSendMessageToCache( - queryClient, - queryKey, - pendingId, - retryOptimisticMessage - ); - const resentMessage = message({ - id: '01KQK8Y3333333333333333334', - senderId: 'user-current', - content: textContent('never delivered'), - }); - settleSendMessageSuccess( - queryClient, - { - messageId: resentMessage.id, - clientId: '01KQK8Y1111111111111111113', - message: resentMessage, - }, - context - ); - - // Mirrors handleRetrySend's onSuccess cache surgery: drop the original - // failed row now that the resend has settled successfully. - removeMessageFromCache(queryClient, queryKey, failedOriginal.id); - - const result = queryClient.getQueryData(queryKey); - expect(result?.pages.flatMap(page => page.messages)).toEqual([resentMessage]); - }); - it('creates the first page for message.created events when the messages cache is cold', () => { const queryClient = new QueryClient(); const queryKey = messagesKey('01KQK8Z0000000000000000000'); diff --git a/services/kilo-chat/src/__tests__/conversation-do.test.ts b/services/kilo-chat/src/__tests__/conversation-do.test.ts index da4540899c..4229af3cad 100644 --- a/services/kilo-chat/src/__tests__/conversation-do.test.ts +++ b/services/kilo-chat/src/__tests__/conversation-do.test.ts @@ -1153,7 +1153,6 @@ describe('ConversationDO', () => { expect(result).toEqual({ ok: true, redelivered: true, - memberContext: { humanMemberIds: ['user-alice'], sandboxId: null }, }); const { messages } = await stub.listMessages({ limit: 10 }); diff --git a/services/kilo-chat/src/do/conversation-do.ts b/services/kilo-chat/src/do/conversation-do.ts index b0d8f7ead1..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, @@ -262,7 +263,7 @@ export type RedeliverMessageParams = { }; export type RedeliverMessageResult = - | { ok: true; redelivered: boolean; memberContext: MemberContext } + | { ok: true; redelivered: boolean } | { ok: false; code: 'not_found' | 'forbidden' | 'conflict'; error: string }; export type InitAttachmentParams = { @@ -489,7 +490,7 @@ export class ConversationDO extends DurableObject { // 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, memberContext }; + return { ok: true, redelivered: false }; } // delivery_failed is one message-level bit — it doesn't record which @@ -530,10 +531,7 @@ export class ConversationDO extends DurableObject { .where(eq(messages.id, row.in_reply_to_message_id)) .get(); if (parent && parent.deleted !== 1) { - inReplyToBody = parseStoredContent(parent.content, parent.id) - .filter((b): b is Extract => b.type === 'text') - .map(b => b.text) - .join(''); + inReplyToBody = contentBlocksToText(parseStoredContent(parent.content, parent.id)); inReplyToSender = parent.sender_id; } } @@ -577,7 +575,7 @@ export class ConversationDO extends DurableObject { }); this.ctx.waitUntil(this.webhookChain); - return { ok: true, redelivered: true, memberContext }; + return { ok: true, redelivered: true }; } revertActionResolution(params: { 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); } From ed8f48df721b2b831ee60ed1d5217d6062ba7fb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 13:10:22 +0200 Subject: [PATCH 147/166] simplify(kilo-chat): reuse useConversationRename in ConversationRow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: ConversationRow hand-rolled the renaming state + open/close/save pattern that useConversationRename already encapsulates — wire it onto the hook. Also update the stale useRenameConversation comment (the rename form sheet is gone; callers now rename via RenameModal) and narrow useConversationOptionsSheet's return to what its caller uses. --- .../components/kilo-chat/conversation-row.tsx | 24 +++++++------------ .../hooks/use-conversation-options-sheet.ts | 7 +++++- .../kilo-chat/hooks/use-conversations.ts | 4 ++-- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/conversation-row.tsx b/apps/mobile/src/components/kilo-chat/conversation-row.tsx index 975ed585e0..ea2d8a1983 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-row.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-row.tsx @@ -1,7 +1,6 @@ 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 { useState } from 'react'; import { MessageSquare, MoreVertical } from 'lucide-react-native'; import { Alert, Pressable, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -11,8 +10,8 @@ import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { timeAgo } from '@/lib/utils'; +import { useConversationRename } from './hooks/use-conversation-rename'; import { useKiloChatClient } from './hooks/use-kilo-chat-client'; -import { useRenameConversation } from './hooks/use-conversations'; type ConversationRowProps = { conversation: ConversationListItem; @@ -46,8 +45,11 @@ export function ConversationRow({ const { bottom } = useSafeAreaInsets(); const { showActionSheetWithOptions } = useActionSheet(); const client = useKiloChatClient(); - const renameConversation = useRenameConversation(client); - const [renaming, setRenaming] = useState(false); + const { renaming, openRename, closeRename, saveRename } = useConversationRename( + client, + conversation.conversationId, + sandboxId + ); const title = conversationTitle(conversation); function confirmLeave() { @@ -76,7 +78,7 @@ export function ConversationRow({ }, index => { if (index === 0) { - setRenaming(true); + openRename(); } else if (index === 1) { confirmLeave(); } @@ -133,16 +135,8 @@ export function ConversationRow({ placeholder="Enter a new name" initialValue={conversation.title ?? ''} maxLength={CONVERSATION_TITLE_MAX_CHARS} - onSave={async name => { - await renameConversation.mutateAsync({ - conversationId: conversation.conversationId, - title: name, - sandboxId, - }); - }} - onClose={() => { - setRenaming(false); - }} + onSave={saveRename} + onClose={closeRename} /> )} 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 index 3f99394f6d..b081572cad 100644 --- 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 @@ -78,5 +78,10 @@ export function useConversationOptionsSheet({ showActionSheetWithOptions, ]); - return { openOptions, ...rename }; + return { + openOptions, + renaming: rename.renaming, + closeRename: rename.closeRename, + saveRename: rename.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 48911d3517..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,8 +19,8 @@ export function useCreateConversation(client: KiloChatClient) { } export function useRenameConversation(client: KiloChatClient) { - // No centralized toast here — the only caller is the rename form sheet, - // which stays open on failure and shows the error inline (see P2). + // No centralized toast here — callers rename via RenameModal, which stays + // open on failure and shows the error inline. return useSharedRenameConversation(client); } From 3b790a9bb1abfd0b4760675cb01d3228501a43d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 13:26:01 +0200 Subject: [PATCH 148/166] simplify(kiloclaw): collapse boundary shell, local provisioning timeout, dedupe org-id/pending helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InstanceContextBoundary now owns its own header+background shell (title prop) instead of relying on dead children passthrough; all 11 callers collapse to a one-line early return. - Move the provisioning wall-clock timeout out of the onboarding machine into local ProvisioningStep state — it had no other producer/consumer and was threaded through three components just to flip one flag. - Restore single-shell + body-switch structure in changelog/channels/secrets screens instead of duplicating the header per branch. - Replace hand-rolled pending-id state + onSettled clearing with React Query's mutation.variables (model-list, device-pairing), matching the pattern already used by exec-policy. - Extract instanceOrgId() helper for the organizationId-from-context dance repeated across ~10 screens. - Collapse REDEPLOY_BLOCKING_STATUSES into a canStart-derived check. - Invalidate billing status via queryClient in the mutation's onSuccess instead of threading an onContinued callback through three components. - Collapse useKiloClawBillingStatus's number|function refetchInterval union to a single function-typed param. - Replace model-list's remount-key search-clear hack with TextInput.clear(). --- .../(app)/kiloclaw/[instance-id]/billing.tsx | 36 ++++-------- .../kiloclaw/[instance-id]/changelog.tsx | 57 ++++++++----------- .../kiloclaw/[instance-id]/dashboard.tsx | 12 +--- .../[instance-id]/settings/channels.tsx | 57 ++++++++----------- .../[instance-id]/settings/device-pairing.tsx | 45 +++++---------- .../[instance-id]/settings/exec-policy.tsx | 12 +--- .../[instance-id]/settings/google.tsx | 12 +--- .../[instance-id]/settings/model-list.tsx | 31 ++++------ .../kiloclaw/[instance-id]/settings/model.tsx | 7 +-- .../[instance-id]/settings/secrets.tsx | 57 ++++++++----------- .../[instance-id]/settings/version-pin.tsx | 12 +--- .../kilo-chat/conversation-screen.tsx | 8 ++- .../kiloclaw/instance-context-boundary.tsx | 44 +++++++------- .../components/kiloclaw/instance-controls.tsx | 17 ++---- .../src/components/kiloclaw/model-picker.tsx | 5 +- .../components/kiloclaw/onboarding-flow.tsx | 5 -- .../kiloclaw/onboarding/flow-body.tsx | 3 - .../kiloclaw/onboarding/provisioning-step.tsx | 29 +++++----- .../src/lib/hooks/use-instance-context.ts | 5 ++ .../src/lib/hooks/use-kiloclaw-mutations.ts | 13 ++++- .../src/lib/hooks/use-kiloclaw-queries.ts | 10 ++-- .../machine-provisioning-terminal.test.ts | 23 ++------ apps/mobile/src/lib/onboarding/machine.ts | 16 ++---- .../src/lib/onboarding/selectors.test.ts | 31 +++++----- apps/mobile/src/lib/onboarding/selectors.ts | 13 +++-- 25 files changed, 222 insertions(+), 338 deletions(-) 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 a9a71a8936..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,5 +1,5 @@ import { formatDollars, fromMicrodollars } from '@kilocode/app-shared/utils'; -import { useMutation } from '@tanstack/react-query'; +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'; @@ -44,14 +44,16 @@ function formatStandardPrice(microdollars: number | null | undefined): string { } /** "Continue month-to-month" CTA shown during a Commit plan's final term. */ -function ContinueMonthToMonthAction({ - instanceId, - onSuccess, -}: Readonly<{ instanceId: string; onSuccess: () => void }>) { +function ContinueMonthToMonthAction({ instanceId }: Readonly<{ instanceId: string }>) { const trpc = useTRPC(); + const queryClient = useQueryClient(); const mutation = useMutation( trpc.kiloclaw.continueCommitAsStandard.mutationOptions({ - onSuccess, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: trpc.kiloclaw.getBillingStatus.queryKey(), + }); + }, onError: error => { toast.error(error.message); }, @@ -74,10 +76,8 @@ function ContinueMonthToMonthAction({ function FinalCommitTermDetails({ billing, - onContinued, }: Readonly<{ billing: NonNullable['data']>; - onContinued: () => void; }>) { const subscription = billing.subscription; if (!subscription) { @@ -108,7 +108,7 @@ function FinalCommitTermDetails({ : `Your final Commit term ends on ${finalDate}. Continue as Standard at ${priceText}, or hosting ends.`} {!subscription.standardContinuationScheduled && instanceId ? ( - + ) : null} @@ -117,13 +117,11 @@ function FinalCommitTermDetails({ function PlanDetails({ billing, - onContinued, }: Readonly<{ billing: NonNullable['data']>; - onContinued: () => void; }>) { if (billing.subscription?.isFinalCommitTerm) { - return ; + return ; } if (billing.subscription) { const planName = @@ -187,12 +185,7 @@ export default function BillingScreen() { const billing = billingQuery.data; if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { - return ( - - - - - ); + return ; } if (isOrg) { @@ -264,12 +257,7 @@ export default function BillingScreen() { {/* Plan details */} - { - void billingQuery.refetch(); - }} - /> + {/* Manage billing button */} 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 e0f758b96b..c2b81794dc 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/changelog.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/changelog.tsx @@ -10,15 +10,14 @@ 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 { 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 instanceContext = useInstanceContext(instanceId); - const organizationId = - instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; + const organizationId = instanceOrgId(instanceContext); const changelogQuery = useKiloClawChangelog(organizationId); const mutations = useKiloClawMutations(organizationId); const router = useRouter(); @@ -42,31 +41,22 @@ export default function ChangelogScreen() { } if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { - return ( - - - - - ); + return ; } - if (changelogQuery.isPending) { - return ( - - + function renderBody() { + if (changelogQuery.isPending) { + return ( - - ); - } + ); + } - if (changelogQuery.isError) { - return ( - - + if (changelogQuery.isError) { + return ( - - ); - } + ); + } - if (!entries || entries.length === 0) { - return ( - - + if (!entries || entries.length === 0) { + return ( - - ); - } + ); + } - return ( - - + return ( + ); + } + + return ( + + + {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 54e9be1b1d..96e936c56d 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx @@ -29,7 +29,7 @@ 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, @@ -49,8 +49,7 @@ export default function DashboardScreen() { const paddingBottom = useDetailScreenBottomPadding(); const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>(); const instanceContext = useInstanceContext(instanceId); - const organizationId = - instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; + const organizationId = instanceOrgId(instanceContext); const isOrg = instanceContext.status === 'ready' && instanceContext.isOrg; const statusQuery = useKiloClawStatus(organizationId); @@ -103,12 +102,7 @@ export default function DashboardScreen() { ); if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { - return ( - - - - - ); + return ; } if (isLoading) { 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 addac50377..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 @@ -9,15 +9,14 @@ 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 instanceContext = useInstanceContext(instanceId); - const organizationId = - instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; + const organizationId = instanceOrgId(instanceContext); const catalogQuery = useKiloClawChannelCatalog(organizationId); const mutations = useKiloClawMutations(organizationId); const paddingBottom = useDetailScreenBottomPadding(); @@ -25,31 +24,22 @@ export default function ChannelsScreen() { const isLoading = catalogQuery.isPending; if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { - return ( - - - - - ); + return ; } - if (isLoading) { - return ( - - + function renderBody() { + if (isLoading) { + return ( - - ); - } + ); + } - if (catalogQuery.isError) { - return ( - - + if (catalogQuery.isError) { + return ( - - ); - } + ); + } - if (catalogQuery.data.length === 0) { - return ( - - + if (catalogQuery.data.length === 0) { + return ( - - ); - } + ); + } - return ( - - + return ( + ); + } + + 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 a72a5a64bc..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 @@ -1,6 +1,6 @@ import { useQueryClient } from '@tanstack/react-query'; import { MessageSquare, Monitor, RefreshCw } from 'lucide-react-native'; -import { useCallback, useState } from 'react'; +import { useCallback } from 'react'; import { Alert, Pressable, ScrollView, View } from 'react-native'; import Animated, { Easing, @@ -21,7 +21,7 @@ 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, @@ -40,16 +40,13 @@ const CHANNEL_LABELS: Record = { export default function DevicePairingScreen() { const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>(); const instanceContext = useInstanceContext(instanceId); - const organizationId = - instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; + 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 [pendingChannelKey, setPendingChannelKey] = useState(null); - const [pendingDeviceRequestId, setPendingDeviceRequestId] = useState(null); const isLoading = pairingQuery.isPending || devicePairingQuery.isPending; @@ -88,12 +85,7 @@ export default function DevicePairingScreen() { ); if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { - return ( - - - - - ); + return ; } if (isLoading) { @@ -139,15 +131,7 @@ export default function DevicePairingScreen() { { text: 'Approve', onPress: () => { - setPendingChannelKey(`${channel}-${code}`); - mutations.approvePairingRequest.mutate( - { channel, code }, - { - onSettled: () => { - setPendingChannelKey(null); - }, - } - ); + mutations.approvePairingRequest.mutate({ channel, code }); }, }, ] @@ -160,15 +144,7 @@ export default function DevicePairingScreen() { { text: 'Approve', onPress: () => { - setPendingDeviceRequestId(requestId); - mutations.approveDevicePairingRequest.mutate( - { requestId }, - { - onSettled: () => { - setPendingDeviceRequestId(null); - }, - } - ); + mutations.approveDevicePairingRequest.mutate({ requestId }); }, }, ]); @@ -208,7 +184,10 @@ export default function DevicePairingScreen() { {channelRequests.map((request, index) => { const ChannelIcon = CATALOG_ICONS[request.channel]; - const isThisPending = pendingChannelKey === `${request.channel}-${request.code}`; + const isThisPending = + mutations.approvePairingRequest.isPending && + mutations.approvePairingRequest.variables.channel === request.channel && + mutations.approvePairingRequest.variables.code === request.code; return ( {index > 0 && } @@ -256,7 +235,9 @@ export default function DevicePairingScreen() { {deviceRequests.map((request, index) => { - const isThisPending = pendingDeviceRequestId === request.requestId; + const isThisPending = + mutations.approveDevicePairingRequest.isPending && + mutations.approveDevicePairingRequest.variables.requestId === request.requestId; return ( {index > 0 && } 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 a7003e8a92..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 @@ -8,7 +8,7 @@ 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'; @@ -56,8 +56,7 @@ function resolvePreset( export default function ExecPolicyScreen() { const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>(); const instanceContext = useInstanceContext(instanceId); - const organizationId = - instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; + const organizationId = instanceOrgId(instanceContext); const statusQuery = useKiloClawStatus(organizationId); const mutations = useKiloClawMutations(organizationId); const paddingBottom = useDetailScreenBottomPadding(); @@ -66,12 +65,7 @@ export default function ExecPolicyScreen() { const currentPreset = resolvePreset(statusQuery.data?.execSecurity, statusQuery.data?.execAsk); if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { - return ( - - - - - ); + return ; } if (statusQuery.isPending) { 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 eda70e3221..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 @@ -13,7 +13,7 @@ import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { captureEvent, INSTANCE_ACTION_EVENT } from '@/lib/analytics/posthog'; -import { useInstanceContext } from '@/lib/hooks/use-instance-context'; +import { instanceOrgId, useInstanceContext } from '@/lib/hooks/use-instance-context'; import { useKiloClawGoogleSetup, useKiloClawMutations, @@ -26,8 +26,7 @@ import { cn } from '@/lib/utils'; export default function GoogleScreen() { const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>(); const instanceContext = useInstanceContext(instanceId); - const organizationId = - instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; + const organizationId = instanceOrgId(instanceContext); const statusQuery = useKiloClawStatus(organizationId); const mutations = useKiloClawMutations(organizationId); const paddingBottom = useDetailScreenBottomPadding(); @@ -42,12 +41,7 @@ export default function GoogleScreen() { const setupQuery = useKiloClawGoogleSetup(organizationId, !statusQuery.isPending && !isConnected); if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { - return ( - - - - - ); + return ; } if (statusQuery.isPending) { 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 eab227b938..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,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { Check, Eye, Search } from 'lucide-react-native'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, FlatList, @@ -18,7 +18,7 @@ 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'; @@ -35,21 +35,17 @@ type ModelItem = { export default function ModelListScreen() { const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>(); const instanceContext = useInstanceContext(instanceId); - const organizationId = - instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; + const organizationId = instanceOrgId(instanceContext); const router = useRouter(); const colors = useThemeColors(); const paddingBottom = useDetailScreenBottomPadding(); const trpc = useTRPC(); const [searchFilter, setSearchFilter] = useState(''); - const [searchKey, setSearchKey] = useState(0); - const [pendingModelId, setPendingModelId] = useState(null); + const searchInputRef = useRef(null); const handleClearSearch = useCallback(() => { setSearchFilter(''); - // TextInput below is uncontrolled (CLAUDE.md) — bump the key to force it - // to remount with an empty value instead of reading state per keystroke. - setSearchKey(k => k + 1); + searchInputRef.current?.clear(); }, []); const configQuery = useKiloClawConfig(organizationId); @@ -90,22 +86,22 @@ export default function ModelListScreen() { const handleSelect = useCallback( (modelId: string) => { - setPendingModelId(modelId); mutations.updateModel.mutate( { kilocodeDefaultModel: addModelPrefix(modelId) }, { onSuccess: () => { router.back(); }, - onSettled: () => { - setPendingModelId(null); - }, } ); }, [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; @@ -160,12 +156,7 @@ export default function ModelListScreen() { ]; if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { - return ( - - - - - ); + return ; } return ( @@ -173,7 +164,7 @@ export default function ModelListScreen() { - - - - ); + 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 3f8d6e563d..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 @@ -9,47 +9,37 @@ 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 instanceContext = useInstanceContext(instanceId); - const organizationId = - instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; + const organizationId = instanceOrgId(instanceContext); const mutations = useKiloClawMutations(organizationId); const catalogQuery = useKiloClawSecretCatalog(organizationId); const paddingBottom = useDetailScreenBottomPadding(); const isLoading = catalogQuery.isPending; if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { - return ( - - - - - ); + return ; } - if (isLoading) { - return ( - - + function renderBody() { + if (isLoading) { + return ( - - ); - } + ); + } - if (catalogQuery.isError) { - return ( - - + if (catalogQuery.isError) { + return ( - - ); - } + ); + } - if (catalogQuery.data.length === 0) { - return ( - - + if (catalogQuery.data.length === 0) { + return ( - - ); - } + ); + } - return ( - - + return ( + ); + } + + 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 51f622cad1..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 @@ -13,7 +13,7 @@ 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, @@ -29,8 +29,7 @@ const MAX_LIMIT = 100; export default function VersionPinScreen() { const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>(); const instanceContext = useInstanceContext(instanceId); - const organizationId = - instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; + const organizationId = instanceOrgId(instanceContext); const myPinQuery = useKiloClawMyPin(organizationId); const latestVersionQuery = useKiloClawLatestVersion(); const [limit, setLimit] = useState(PAGE_SIZE); @@ -47,12 +46,7 @@ export default function VersionPinScreen() { const isPinMutating = mutations.setMyPin.isPending || mutations.removeMyPin.isPending; if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { - return ( - - - - - ); + return ; } if (isLoading) { diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index f18122f7f0..6d62c0f3d5 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -29,7 +29,11 @@ import { useMessageCacheUpdater, useMessages } from './hooks/use-messages'; import { useNowTicker } from './hooks/use-now-ticker'; import { useCurrentUserId } from './hooks/use-current-user-id'; import { useKiloChatTokenError } from './kilo-chat-provider'; -import { useAllKiloClawInstances, useInstanceContext } from '@/lib/hooks/use-instance-context'; +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 } from '@/lib/kilo-chat-routes'; @@ -57,7 +61,7 @@ export function ConversationScreen({ const tokenError = useKiloChatTokenError(); const instanceContext = useInstanceContext(sandboxId); const instanceStatusQuery = useKiloClawStatus( - instanceContext.status === 'ready' ? instanceContext.organizationId : undefined, + instanceOrgId(instanceContext), instanceContext.status === 'ready' ); const { data: instances } = useAllKiloClawInstances(); diff --git a/apps/mobile/src/components/kiloclaw/instance-context-boundary.tsx b/apps/mobile/src/components/kiloclaw/instance-context-boundary.tsx index 1f54a2cfbd..01378a44b8 100644 --- a/apps/mobile/src/components/kiloclaw/instance-context-boundary.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-context-boundary.tsx @@ -1,44 +1,48 @@ import { type Href, useRouter } from 'expo-router'; import { SearchX } from 'lucide-react-native'; -import { type ReactNode } from 'react'; 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; - children?: ReactNode; }; /** - * Renders the terminal states of `useInstanceContext`: an error with retry, - * or an "instance not found" empty state (destroyed instance / stale deep - * link). Renders `children` for `loading`/`ready` — screens keep their own - * loading skeletons, this only covers the states they used to collapse into - * an eternal skeleton. + * 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({ context, children }: Readonly) { +export function InstanceContextBoundary({ title, context }: Readonly) { const router = useRouter(); if (context.status === 'error') { return ( - - { - context.refetch(); - }} - /> + + + + { + context.refetch(); + }} + /> + ); } - if (context.status === 'not_found') { - return ( + return ( + + ) } /> - ); - } - - return <>{children}; + + ); } diff --git a/apps/mobile/src/components/kiloclaw/instance-controls.tsx b/apps/mobile/src/components/kiloclaw/instance-controls.tsx index 46b98d5e4e..99d2bac4d5 100644 --- a/apps/mobile/src/components/kiloclaw/instance-controls.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-controls.tsx @@ -12,8 +12,10 @@ type InstanceControlsProps = { // Statuses where the backend is already mid-transition — starting or // redeploying now would race an in-flight lifecycle change. Anything NOT in -// these sets (including 'stopped', 'provisioned', 'crashed', and any -// unrecognized/null status) is fair game. +// 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', @@ -24,21 +26,12 @@ const START_BLOCKING_STATUSES = new Set([ 'recovering', 'restoring', ]); -const REDEPLOY_BLOCKING_STATUSES = new Set([ - 'starting', - 'restarting', - 'stopping', - 'shutting_down', - 'destroying', - 'recovering', - 'restoring', -]); export function InstanceControls({ status, mutations }: Readonly) { const canStart = status == null || !START_BLOCKING_STATUSES.has(status); const canStop = status === 'running'; const canRestartOpenClaw = status === 'running'; - const canRedeploy = status == null || !REDEPLOY_BLOCKING_STATUSES.has(status); + 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), diff --git a/apps/mobile/src/components/kiloclaw/model-picker.tsx b/apps/mobile/src/components/kiloclaw/model-picker.tsx index e36452dc73..f4da7bf345 100644 --- a/apps/mobile/src/components/kiloclaw/model-picker.tsx +++ b/apps/mobile/src/components/kiloclaw/model-picker.tsx @@ -4,7 +4,7 @@ import { Pressable, View } from 'react-native'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; -import { useInstanceContext } from '@/lib/hooks/use-instance-context'; +import { instanceOrgId, useInstanceContext } from '@/lib/hooks/use-instance-context'; import { useKiloClawConfig, useKiloClawMutations } from '@/lib/hooks/use-kiloclaw-queries'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { addModelPrefix, stripModelPrefix } from '@/lib/model-id'; @@ -86,8 +86,7 @@ export function ModelPicker() { const router = useRouter(); const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>(); const instanceContext = useInstanceContext(instanceId); - const organizationId = - instanceContext.status === 'ready' ? instanceContext.organizationId : undefined; + const organizationId = instanceOrgId(instanceContext); const { data: config, isLoading } = useKiloClawConfig(organizationId); const mutations = useKiloClawMutations(organizationId); const colors = useThemeColors(); diff --git a/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx b/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx index ab6c38d81d..5ec1847f51 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx @@ -292,10 +292,6 @@ export function OnboardingFlow() { dispatch({ type: 'gateway-grace-elapsed' }); }, []); - const onProvisioningTimeout = useCallback(() => { - dispatch({ type: 'provisioning-timeout-elapsed' }); - }, []); - const onDismiss = useCallback(() => { // When provision has started (or an instance exists), land on Home so the // user sees the live status card. Otherwise return to the entry screen. @@ -457,7 +453,6 @@ export function OnboardingFlow() { onProvisioningComplete={onProvisioningComplete} onRetry={onProvisioningRetry} onGraceElapsed={onGraceElapsed} - onProvisioningTimeout={onProvisioningTimeout} onContinueInBackground={onDismiss} onOpenInstance={onOpenInstance} /> diff --git a/apps/mobile/src/components/kiloclaw/onboarding/flow-body.tsx b/apps/mobile/src/components/kiloclaw/onboarding/flow-body.tsx index 7c8f09df77..2656771ded 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding/flow-body.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding/flow-body.tsx @@ -22,7 +22,6 @@ type FlowBodyProps = { onProvisioningComplete: () => void; onRetry: () => void; onGraceElapsed: () => void; - onProvisioningTimeout: () => void; onContinueInBackground: () => void; onOpenInstance: () => void; }; @@ -35,7 +34,6 @@ export function FlowBody(props: Readonly) { onProvisioningComplete, onRetry, onGraceElapsed, - onProvisioningTimeout, onContinueInBackground, onOpenInstance, } = props; @@ -152,7 +150,6 @@ export function FlowBody(props: Readonly) { state={state} onComplete={onProvisioningComplete} onGraceElapsed={onGraceElapsed} - onProvisioningTimeout={onProvisioningTimeout} onRetry={onRetry} onContinueInBackground={onContinueInBackground} /> diff --git a/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx b/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx index 3ab962dfc0..38729ab704 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx @@ -31,7 +31,6 @@ import { DEFAULT_BOT_IDENTITY } from './state'; type ProvisioningStepProps = { state: OnboardingState; onGraceElapsed: () => void; - onProvisioningTimeout: () => void; onComplete: () => void; onRetry: () => void; onContinueInBackground: () => void; @@ -83,7 +82,6 @@ function provisioningStageMessage(state: OnboardingState): string { export function ProvisioningStep({ state, onGraceElapsed, - onProvisioningTimeout, onComplete, onRetry, onContinueInBackground, @@ -94,7 +92,14 @@ export function ProvisioningStep({ const botName = state.botIdentity?.botName ?? DEFAULT_BOT_IDENTITY.botName; const tint = agentColor(botEmoji); - const terminalReason = getProvisioningTerminalReason(state); + // Overall wall-clock timeout: local to this component since it's the only + // producer and consumer. Resets naturally on retry, same as `enteredAtMs` + // below — a retry sends `state.step` back to `identity`, which unmounts + // this component (see `key="provisioning"` in `flow-body.tsx`), so both + // reset fresh on the next entry into provisioning. + const [timedOut, setTimedOut] = useState(false); + + const terminalReason = getProvisioningTerminalReason(state, timedOut); const stageMessage = useMemo(() => provisioningStageMessage(state), [state]); // Gentle breathing pulse on the avatar tile — signals "actively working" @@ -129,8 +134,8 @@ export function ProvisioningStep({ // Single 1s poll while provisioning is live and not yet terminal: checks // both the 502-grace sub-machine (pure `checkGraceExpired` helper) and the - // overall wall-clock budget, dispatching whichever fires. - const { first502AtMs, gateway502Expired, provisioningTimedOut } = state; + // overall wall-clock budget, acting on whichever fires. + const { first502AtMs, gateway502Expired } = state; useEffect(() => { if (terminalReason !== null) { return undefined; @@ -144,22 +149,14 @@ export function ProvisioningStep({ ) { onGraceElapsed(); } - if (!provisioningTimedOut && nowMs - enteredAtMs >= OVERALL_TIMEOUT_MS) { - onProvisioningTimeout(); + if (!timedOut && nowMs - enteredAtMs >= OVERALL_TIMEOUT_MS) { + setTimedOut(true); } }, 1000); return () => { clearInterval(interval); }; - }, [ - terminalReason, - first502AtMs, - gateway502Expired, - provisioningTimedOut, - enteredAtMs, - onGraceElapsed, - onProvisioningTimeout, - ]); + }, [terminalReason, first502AtMs, gateway502Expired, timedOut, enteredAtMs, onGraceElapsed]); // Advance to the done step once the instance + gateway gate holds. Step // saves are dispatched from OnboardingFlow and their apply is guaranteed by diff --git a/apps/mobile/src/lib/hooks/use-instance-context.ts b/apps/mobile/src/lib/hooks/use-instance-context.ts index b071fb185d..e9ad5aca9f 100644 --- a/apps/mobile/src/lib/hooks/use-instance-context.ts +++ b/apps/mobile/src/lib/hooks/use-instance-context.ts @@ -26,6 +26,11 @@ export function useAllKiloClawInstances(refetchInterval: number | ListPollDecide ); } +/** 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 query = useQuery( diff --git a/apps/mobile/src/lib/hooks/use-kiloclaw-mutations.ts b/apps/mobile/src/lib/hooks/use-kiloclaw-mutations.ts index a36c31bc02..9c49258d5b 100644 --- a/apps/mobile/src/lib/hooks/use-kiloclaw-mutations.ts +++ b/apps/mobile/src/lib/hooks/use-kiloclaw-mutations.ts @@ -322,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 @@ -332,7 +336,7 @@ export function useKiloClawMutations(organizationId?: string | null) { }, onError: onMutationError, }), - approveDevicePairingRequest: useMutation({ + approveDevicePairingRequest: useMutation({ ...dispatch( trpc.kiloclaw.approveDevicePairingRequest, trpc.organizations.kiloclaw.approveDevicePairingRequest @@ -409,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 b425f8a403..4eb64560ef 100644 --- a/apps/mobile/src/lib/hooks/use-kiloclaw-queries.ts +++ b/apps/mobile/src/lib/hooks/use-kiloclaw-queries.ts @@ -53,17 +53,15 @@ type BillingPollDecider = (data: BillingData | undefined) => number; export function useKiloClawBillingStatus( enabled = true, - refetchInterval: number | BillingPollDecider = 60_000 + refetchInterval: BillingPollDecider = () => 60_000 ) { const trpc = useTRPC(); - const intervalOption = - typeof refetchInterval === 'function' - ? (query: { state: { data?: BillingData } }) => refetchInterval(query.state.data) - : refetchInterval; return useQuery( trpc.kiloclaw.getBillingStatus.queryOptions(undefined, { enabled, - refetchInterval: enabled ? intervalOption : false, + refetchInterval: enabled + ? (query: { state: { data?: BillingData } }) => refetchInterval(query.state.data) + : false, }) ); } diff --git a/apps/mobile/src/lib/onboarding/machine-provisioning-terminal.test.ts b/apps/mobile/src/lib/onboarding/machine-provisioning-terminal.test.ts index 59d04f9e43..179a3e9c80 100644 --- a/apps/mobile/src/lib/onboarding/machine-provisioning-terminal.test.ts +++ b/apps/mobile/src/lib/onboarding/machine-provisioning-terminal.test.ts @@ -1,8 +1,9 @@ /** * Reducer coverage for the provisioning terminal signals added alongside - * `getProvisioningTerminalReason` (query errors, the overall wall-clock - * timeout, and their reset on retry). Split out of `machine.test.ts` to stay - * under the file's line budget. + * `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'; @@ -23,21 +24,9 @@ describe('provisioning-query-errored', () => { }); }); -describe('provisioning-timeout-elapsed', () => { - it('flips provisioningTimedOut', () => { - const s = reduce(INITIAL_STATE, { type: 'provisioning-timeout-elapsed' }); - expect(s.provisioningTimedOut).toBe(true); - }); -}); - describe('retry-requested clears terminal signals', () => { - it('clears queryErrored and provisioningTimedOut', () => { - const s = run([ - { type: 'provisioning-query-errored' }, - { type: 'provisioning-timeout-elapsed' }, - { type: 'retry-requested' }, - ]); + it('clears queryErrored', () => { + const s = run([{ type: 'provisioning-query-errored' }, { type: 'retry-requested' }]); expect(s.queryErrored).toBe(false); - expect(s.provisioningTimedOut).toBe(false); }); }); diff --git a/apps/mobile/src/lib/onboarding/machine.ts b/apps/mobile/src/lib/onboarding/machine.ts index bab83053e9..8e74cb8056 100644 --- a/apps/mobile/src/lib/onboarding/machine.ts +++ b/apps/mobile/src/lib/onboarding/machine.ts @@ -81,11 +81,12 @@ export type OnboardingState = { first502AtMs: number | null; gateway502Expired: boolean; - // Terminal signals fed in from outside the 502-grace sub-machine: a hard - // query error (status/gateway-ready query failed) or the overall - // provisioning timeout (see `./selectors.ts` `getProvisioningTerminalReason`). + // 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; - provisioningTimedOut: boolean; // Analytics fire-once guards. onboardingEnteredFired: boolean; @@ -113,7 +114,6 @@ export const INITIAL_STATE: OnboardingState = { first502AtMs: null, gateway502Expired: false, queryErrored: false, - provisioningTimedOut: false, onboardingEnteredFired: false, completionReachedFired: false, }; @@ -137,7 +137,6 @@ export type OnboardingEvent = } | { type: 'gateway-grace-elapsed' } | { type: 'provisioning-query-errored' } - | { type: 'provisioning-timeout-elapsed' } | { type: 'bot-identity-saved' } | { type: 'exec-preset-saved' } @@ -259,10 +258,6 @@ export function reduce(state: OnboardingState, event: OnboardingEvent): Onboardi return { ...state, queryErrored: true }; } - case 'provisioning-timeout-elapsed': { - return { ...state, provisioningTimedOut: true }; - } - case 'provisioning-complete-acknowledged': { return { ...state, step: 'done' }; } @@ -285,7 +280,6 @@ export function reduce(state: OnboardingState, event: OnboardingEvent): Onboardi first502AtMs: null, gateway502Expired: false, queryErrored: false, - provisioningTimedOut: false, }; } diff --git a/apps/mobile/src/lib/onboarding/selectors.test.ts b/apps/mobile/src/lib/onboarding/selectors.test.ts index 6e73788f52..89ba5f70ba 100644 --- a/apps/mobile/src/lib/onboarding/selectors.test.ts +++ b/apps/mobile/src/lib/onboarding/selectors.test.ts @@ -220,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', () => { @@ -231,31 +231,30 @@ 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)).toBe(true); - expect(getProvisioningTerminalReason(s)).toBe('query_error'); + 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)).toBe(true); - expect(getProvisioningTerminalReason(s)).toBe('instance_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)).toBe(false); - expect(getProvisioningTerminalReason(s)).toBeNull(); + expect(isProvisioningTerminal(s, false)).toBe(false); + expect(getProvisioningTerminalReason(s, false)).toBeNull(); }); it('is true once the overall provisioning timeout elapses', () => { - const s = reduce(INITIAL_STATE, { type: 'provisioning-timeout-elapsed' }); - expect(isProvisioningTerminal(s)).toBe(true); - expect(getProvisioningTerminalReason(s)).toBe('timeout'); + expect(isProvisioningTerminal(INITIAL_STATE, true)).toBe(true); + expect(getProvisioningTerminalReason(INITIAL_STATE, true)).toBe('timeout'); }); it('prioritizes query_error over an instance_stopped status', () => { @@ -263,15 +262,11 @@ describe('isProvisioningTerminal', () => { { type: 'instance-status-changed', status: 'stopped' }, { type: 'provisioning-query-errored' }, ]); - expect(getProvisioningTerminalReason(s)).toBe('query_error'); + 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: 'provisioning-timeout-elapsed' }, - { type: 'retry-requested' }, - ]); - expect(isProvisioningTerminal(s)).toBe(false); + 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 61c2f565a7..99c68c0ddc 100644 --- a/apps/mobile/src/lib/onboarding/selectors.ts +++ b/apps/mobile/src/lib/onboarding/selectors.ts @@ -109,9 +109,14 @@ export type ProvisioningTerminalReason = * 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 + state: OnboardingState, + timedOut: boolean ): ProvisioningTerminalReason | null { if (state.queryErrored) { return 'query_error'; @@ -122,7 +127,7 @@ export function getProvisioningTerminalReason( if (state.gateway502Expired) { return 'gateway_502'; } - if (state.provisioningTimedOut) { + if (timedOut) { return 'timeout'; } return null; @@ -132,6 +137,6 @@ export function getProvisioningTerminalReason( * Whether the provisioning step should render its terminal "Provisioning failed" * view. True iff `getProvisioningTerminalReason` finds a terminal cause. */ -export function isProvisioningTerminal(state: OnboardingState): boolean { - return getProvisioningTerminalReason(state) !== null; +export function isProvisioningTerminal(state: OnboardingState, timedOut: boolean): boolean { + return getProvisioningTerminalReason(state, timedOut) !== null; } From 641c7b91c6f2d7332f951ab5645ef31f8edbd989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 13:39:35 +0200 Subject: [PATCH 149/166] simplify(code-reviewer): validate scope+platform once in a layout Adds [platform]/_layout.tsx to validate the scope+platform route params once for all six code-reviewer platform routes, instead of each route calling useValidatedReviewerRouteParams() and branching on InvalidRouteState itself. The five config-editing routes (style, gate, focus-areas, repos, instructions) move into an (edit) route group whose own layout runs useReviewerEditGuard once; the group adds no URL segment so existing links are unaffected. Drops the forced Route/RouteContent split in every route now that there's no null check to gate on. --- .../code-reviewer/[scope]/[platform]/{ => (edit)}/focus-areas.tsx | 0 .../code-reviewer/[scope]/[platform]/{ => (edit)}/gate.tsx | 0 .../[scope]/[platform]/{ => (edit)}/instructions.tsx | 0 .../code-reviewer/[scope]/[platform]/{ => (edit)}/repos.tsx | 0 .../code-reviewer/[scope]/[platform]/{ => (edit)}/style.tsx | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/{ => (edit)}/focus-areas.tsx (100%) rename apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/{ => (edit)}/gate.tsx (100%) rename apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/{ => (edit)}/instructions.tsx (100%) rename apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/{ => (edit)}/repos.tsx (100%) rename apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/{ => (edit)}/style.tsx (100%) 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 100% 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 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 100% 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 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 100% 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 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]/(edit)/repos.tsx similarity index 100% rename from apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/repos.tsx rename to apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/repos.tsx 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 100% 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 From f6ad125100a3d1114976506ba546139624c2f02d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 13:40:18 +0200 Subject: [PATCH 150/166] simplify(code-reviewer): drop per-route param validation and edit guard Content half of the previous commit's route restructure: the layout now does the validation/guard work, so each of the six route files just reads the already-validated params via useLocalSearchParams instead of re-running useValidatedReviewerRouteParams()/useReviewerEditGuard and branching into a Route/RouteContent split. --- .../[scope]/[platform]/(edit)/_layout.tsx | 15 ++++++ .../[scope]/[platform]/(edit)/focus-areas.tsx | 49 ++++--------------- .../[scope]/[platform]/(edit)/gate.tsx | 28 ++--------- .../[platform]/(edit)/instructions.tsx | 28 ++--------- .../[scope]/[platform]/(edit)/repos.tsx | 23 +-------- .../[scope]/[platform]/(edit)/style.tsx | 28 ++--------- .../[scope]/[platform]/_layout.tsx | 18 +++++++ .../[scope]/[platform]/index.tsx | 16 +++--- 8 files changed, 61 insertions(+), 144 deletions(-) create mode 100644 apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/_layout.tsx create mode 100644 apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/_layout.tsx 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]/(edit)/focus-areas.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/focus-areas.tsx index 5a4705c98d..5584d1e536 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/focus-areas.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/focus-areas.tsx @@ -1,42 +1,19 @@ -import * as Haptics from 'expo-haptics'; -import { type Href } from 'expo-router'; -import { Check } from 'lucide-react-native'; -import { Pressable, View } from 'react-native'; +import { useLocalSearchParams } from 'expo-router'; +import { View } from 'react-native'; -import { InvalidRouteState } from '@/components/invalid-route-state'; import { ScreenHeader } from '@/components/screen-header'; import { Text } from '@/components/ui/text'; +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, - useReviewerEditGuard, useSaveReviewConfig, } from '@/lib/hooks/use-code-reviewer'; -import { useValidatedReviewerRouteParams } from '@/lib/hooks/use-reviewer-route-params'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { cn } from '@/lib/utils'; export default function FocusAreasRoute() { - const params = useValidatedReviewerRouteParams(); - - if (!params) { - return ; - } - - return ; -} - -function FocusAreasRouteContent({ - scope, - platform, -}: Readonly<{ - scope: string; - platform: ReviewerPlatform; -}>) { - const colors = useThemeColors(); - useReviewerEditGuard(scope, platform); + const { scope, platform } = useLocalSearchParams<{ scope: string; platform: ReviewerPlatform }>(); const { data } = useReviewConfig(scope, platform); const save = useSaveReviewConfig(scope, platform); const readConfig = useReviewConfigCacheReader(scope, platform); @@ -44,7 +21,6 @@ function FocusAreasRouteContent({ 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. @@ -63,22 +39,17 @@ function FocusAreasRouteContent({ 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]/(edit)/gate.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/gate.tsx index cf5e3d9d58..78a0210433 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/gate.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/gate.tsx @@ -1,14 +1,8 @@ -import { type Href } from 'expo-router'; +import { useLocalSearchParams } from 'expo-router'; import { OptionList } from '@/components/code-reviewer/option-list'; -import { InvalidRouteState } from '@/components/invalid-route-state'; import { GATE_THRESHOLDS, type ReviewerPlatform } from '@/lib/code-reviewer-config'; -import { - useReviewConfig, - useReviewerEditGuard, - useSaveReviewConfig, -} from '@/lib/hooks/use-code-reviewer'; -import { useValidatedReviewerRouteParams } from '@/lib/hooks/use-reviewer-route-params'; +import { useReviewConfig, useSaveReviewConfig } from '@/lib/hooks/use-code-reviewer'; const DESCRIPTIONS = { off: 'Never fail the PR check', @@ -18,23 +12,7 @@ const DESCRIPTIONS = { } as const; export default function GateThresholdRoute() { - const params = useValidatedReviewerRouteParams(); - - if (!params) { - return ; - } - - return ; -} - -function GateThresholdRouteContent({ - scope, - platform, -}: Readonly<{ - scope: string; - platform: ReviewerPlatform; -}>) { - useReviewerEditGuard(scope, platform); + const { scope, platform } = useLocalSearchParams<{ scope: string; platform: ReviewerPlatform }>(); const { data } = useReviewConfig(scope, platform); const save = useSaveReviewConfig(scope, platform); diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/instructions.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/instructions.tsx index 5f9c2ebc5f..63a5f82bd8 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/instructions.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/instructions.tsx @@ -1,21 +1,15 @@ -import { type Href, useRouter } from 'expo-router'; +import { useLocalSearchParams, useRouter } from 'expo-router'; import { useRef } from 'react'; import { TextInput, View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; -import { InvalidRouteState } from '@/components/invalid-route-state'; 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 { type ReviewerPlatform } from '@/lib/code-reviewer-config'; -import { - useReviewConfig, - useReviewerEditGuard, - useSaveReviewConfig, -} from '@/lib/hooks/use-code-reviewer'; -import { useValidatedReviewerRouteParams } from '@/lib/hooks/use-reviewer-route-params'; +import { useReviewConfig, useSaveReviewConfig } from '@/lib/hooks/use-code-reviewer'; // Mounted only once `data != null`, so useRef(initial) captures the real // loaded value instead of the pre-fetch default. @@ -60,24 +54,8 @@ function InstructionsEditor({ } export default function InstructionsRoute() { - const params = useValidatedReviewerRouteParams(); - - if (!params) { - return ; - } - - return ; -} - -function InstructionsRouteContent({ - scope, - platform, -}: Readonly<{ - scope: string; - platform: ReviewerPlatform; -}>) { + const { scope, platform } = useLocalSearchParams<{ scope: string; platform: ReviewerPlatform }>(); const router = useRouter(); - useReviewerEditGuard(scope, platform); const { data } = useReviewConfig(scope, platform); const save = useSaveReviewConfig(scope, platform); 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 index 8859e643ff..4295847a9a 100644 --- 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 @@ -1,10 +1,9 @@ import * as Haptics from 'expo-haptics'; -import { type Href } from 'expo-router'; +import { useLocalSearchParams } from 'expo-router'; import { Check, FolderGit2, Lock } from 'lucide-react-native'; import { Pressable, View } from 'react-native'; import { EmptyState } from '@/components/empty-state'; -import { InvalidRouteState } from '@/components/invalid-route-state'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; import { Button } from '@/components/ui/button'; @@ -22,33 +21,15 @@ import { useGitLabRepositories, useReviewConfig, useReviewConfigCacheReader, - useReviewerEditGuard, useSaveReviewConfig, } from '@/lib/hooks/use-code-reviewer'; -import { useValidatedReviewerRouteParams } from '@/lib/hooks/use-reviewer-route-params'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { getBitbucketIntegrationUrl, getGitLabIntegrationUrl } from '@/lib/integration-urls'; import { cn } from '@/lib/utils'; export default function ReposRoute() { - const params = useValidatedReviewerRouteParams(); - - if (!params) { - return ; - } - - return ; -} - -function ReposRouteContent({ - scope, - platform, -}: Readonly<{ - scope: string; - platform: ReviewerPlatform; -}>) { + const { scope, platform } = useLocalSearchParams<{ scope: string; platform: ReviewerPlatform }>(); const colors = useThemeColors(); - useReviewerEditGuard(scope, platform); const { data } = useReviewConfig(scope, platform); const save = useSaveReviewConfig(scope, platform); const readConfig = useReviewConfigCacheReader(scope, platform); diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/style.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/style.tsx index 7e81dbd0d8..fe5300b3c8 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/style.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/style.tsx @@ -1,14 +1,8 @@ -import { type Href } from 'expo-router'; +import { useLocalSearchParams } from 'expo-router'; import { OptionList } from '@/components/code-reviewer/option-list'; -import { InvalidRouteState } from '@/components/invalid-route-state'; import { REVIEW_STYLES, type ReviewerPlatform } from '@/lib/code-reviewer-config'; -import { - useReviewConfig, - useReviewerEditGuard, - useSaveReviewConfig, -} from '@/lib/hooks/use-code-reviewer'; -import { useValidatedReviewerRouteParams } from '@/lib/hooks/use-reviewer-route-params'; +import { useReviewConfig, useSaveReviewConfig } from '@/lib/hooks/use-code-reviewer'; const DESCRIPTIONS = { strict: 'Flag everything, hold a high bar', @@ -18,23 +12,7 @@ const DESCRIPTIONS = { } as const; export default function ReviewStyleRoute() { - const params = useValidatedReviewerRouteParams(); - - if (!params) { - return ; - } - - return ; -} - -function ReviewStyleRouteContent({ - scope, - platform, -}: Readonly<{ - scope: string; - platform: ReviewerPlatform; -}>) { - useReviewerEditGuard(scope, platform); + const { scope, platform } = useLocalSearchParams<{ scope: string; platform: ReviewerPlatform }>(); const { data } = useReviewConfig(scope, platform); const save = useSaveReviewConfig(scope, platform); 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 4b9b7cba73..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,15 +1,13 @@ -import { type Href } from 'expo-router'; +import { useLocalSearchParams } from 'expo-router'; import { PlatformOverviewScreen } from '@/components/code-reviewer/platform-overview-screen'; -import { InvalidRouteState } from '@/components/invalid-route-state'; -import { useValidatedReviewerRouteParams } from '@/lib/hooks/use-reviewer-route-params'; +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 params = useValidatedReviewerRouteParams(); + const { scope, platform } = useLocalSearchParams<{ scope: string; platform: ReviewerPlatform }>(); - if (!params) { - return ; - } - - return ; + return ; } From ec4f91dc2640f484ab5bcd938fe5bbc9189e3c23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 13:44:37 +0200 Subject: [PATCH 151/166] simplify: hoist pick()/capitalize() to lib/utils, reuse buildOverviewRows pick() and capitalize() each existed verbatim in 2-4 places; both now live once in @/lib/utils and every call site imports the shared version (finding-row's severityLabel was exactly capitalize). bitbucket-overview.tsx's hand-rolled overview rows are now built with buildOverviewRows(PLATFORM_CAPABILITIES.bitbucket, ...), matching what it already produced for bitbucket's capability flags; resolveRowOnPress moves alongside buildOverviewRows in platform-overview-rows.ts so both overview screens share one copy instead of two identical locals. Also deletes platform-overview-screen.tsx's bitbucket+personal "Not available" EmptyState: the sole caller gates on parseReviewerPlatform, which already returns null for that combination, so the branch was unreachable re-validation of an already-trusted route param. --- .../code-reviewer/bitbucket-overview.tsx | 86 +++++-------------- .../code-reviewer/platform-overview-rows.ts | 22 ++++- .../platform-overview-screen.tsx | 39 ++------- .../components/security-agent/finding-row.tsx | 10 +-- .../settings-overview-screen.tsx | 5 +- .../mobile/src/lib/hooks/use-code-reviewer.ts | 12 +-- .../lib/hooks/use-security-agent-mutations.ts | 12 +-- apps/mobile/src/lib/utils.ts | 26 +++++- 8 files changed, 77 insertions(+), 135 deletions(-) diff --git a/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx b/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx index abd4ba285b..902b532183 100644 --- a/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx +++ b/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx @@ -1,17 +1,14 @@ import * as Haptics from 'expo-haptics'; import { type Href, useRouter } from 'expo-router'; -import { - FileSliders, - FolderGit2, - MessageSquareText, - ScrollText, - ShieldCheck, -} from 'lucide-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'; @@ -33,7 +30,6 @@ import { 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, @@ -117,64 +113,22 @@ export function BitbucketOverview({ 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', - }, - { - field: 'instructions', - icon: ScrollText, - title: 'Custom Instructions', - subtitle: data.customInstructions ? 'Set' : 'None', + : 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: '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 ( @@ -278,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)} /> ))} diff --git a/apps/mobile/src/components/code-reviewer/platform-overview-rows.ts b/apps/mobile/src/components/code-reviewer/platform-overview-rows.ts index cc1cf11795..348fb29556 100644 --- a/apps/mobile/src/components/code-reviewer/platform-overview-rows.ts +++ b/apps/mobile/src/components/code-reviewer/platform-overview-rows.ts @@ -10,8 +10,7 @@ import { import { type PLATFORM_CAPABILITIES, type ReviewConfigData } from '@/lib/code-reviewer-config'; import { type ModelOption } from '@/lib/hooks/use-available-models'; - -const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1); +import { capitalize } from '@/lib/utils'; type OverviewRow = { field: string; @@ -87,3 +86,22 @@ export function buildOverviewRows({ }, ]; } + +/** 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 4012d1c34c..38194764d2 100644 --- a/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx @@ -1,21 +1,22 @@ import * as Haptics from 'expo-haptics'; import { type Href, useRouter } from 'expo-router'; -import { Building2 } from 'lucide-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 { PlatformErrorScreen } from '@/components/code-reviewer/platform-error-screen'; -import { buildOverviewRows } from '@/components/code-reviewer/platform-overview-rows'; +import { + buildOverviewRows, + resolveRowOnPress, +} from '@/components/code-reviewer/platform-overview-rows'; import { ProviderConnectCard } from '@/components/code-reviewer/provider-connect-card'; -import { EmptyState } from '@/components/empty-state'; 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 { TabScreenScrollView } from '@/components/tab-screen'; import { PLATFORM_CAPABILITIES, type ReviewerPlatform } from '@/lib/code-reviewer-config'; import { useAvailableModels } from '@/lib/hooks/use-available-models'; import { @@ -37,7 +38,6 @@ export function PlatformOverviewScreen({ }: Readonly<{ scope: string; platform: ReviewerPlatform }>) { const router = useRouter(); const colors = useThemeColors(); - const paddingBottom = useTabBarBottomPadding(); const capabilities = PLATFORM_CAPABILITIES[platform]; const githubStatus = useGitHubStatus(scope); const gitlabStatus = useGitLabStatus(scope); @@ -51,21 +51,6 @@ export function PlatformOverviewScreen({ scope === PERSONAL_SCOPE ? undefined : scope ); - if (platform === 'bitbucket' && scope === PERSONAL_SCOPE) { - return ( - - - - - - - ); - } - if (permission.status === 'error') { return ( [number]) => { - if (!canEdit) { - return undefined; - } - if ('onPress' in row) { - return row.onPress; - } - return () => { - pushField(row.field); - }; - }; - return ( @@ -283,7 +256,7 @@ export function PlatformOverviewScreen({ title={row.title} subtitle={row.subtitle} last={index === rows.length - 1} - onPress={resolveRowOnPress(row)} + onPress={resolveRowOnPress(row, canEdit, pushField)} /> ))} diff --git a/apps/mobile/src/components/security-agent/finding-row.tsx b/apps/mobile/src/components/security-agent/finding-row.tsx index 6d4cdd3ada..f4f7a3c9e7 100644 --- a/apps/mobile/src/components/security-agent/finding-row.tsx +++ b/apps/mobile/src/components/security-agent/finding-row.tsx @@ -17,7 +17,7 @@ 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', @@ -26,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 @@ -179,7 +175,7 @@ export function FindingRow({ { router.push(getSecurityAgentPath(scope, `findings/${finding.id}`)); }} @@ -190,7 +186,7 @@ export function FindingRow({ SEVERITY_TEXT_CLASS[finding.severity] ?? 'text-muted-foreground' )} > - {severityLabel(finding.severity)} + {capitalize(finding.severity)} {finding.title} 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 de2e4f23e3..1ed11fe230 100644 --- a/apps/mobile/src/components/security-agent/settings-overview-screen.tsx +++ b/apps/mobile/src/components/security-agent/settings-overview-screen.tsx @@ -21,6 +21,7 @@ import { } from '@/lib/hooks/use-security-agent'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { getSecurityAgentPath } from '@/lib/security-agent'; +import { capitalize } from '@/lib/utils'; function SettingsOverviewSkeleton() { return ( @@ -35,10 +36,6 @@ 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 colors = useThemeColors(); diff --git a/apps/mobile/src/lib/hooks/use-code-reviewer.ts b/apps/mobile/src/lib/hooks/use-code-reviewer.ts index 6bb74ba138..0da1b14a80 100644 --- a/apps/mobile/src/lib/hooks/use-code-reviewer.ts +++ b/apps/mobile/src/lib/hooks/use-code-reviewer.ts @@ -10,6 +10,7 @@ import { } from '@/lib/code-reviewer-config'; import { chainSave } from '@/lib/hooks/save-chain'; import { trpcClient, useTRPC } from '@/lib/trpc'; +import { pick } from '@/lib/utils'; export { PERSONAL_SCOPE }; @@ -138,17 +139,6 @@ 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); diff --git a/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts b/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts index 2016697f32..c9c5ca54d4 100644 --- a/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts +++ b/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts @@ -5,6 +5,7 @@ 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 @@ -16,17 +17,6 @@ function useSecurityAgentConfigQueryKey(scope: string) { : trpc.organizations.securityAgent.getConfig.queryKey({ organizationId: scope }); } -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(); diff --git a/apps/mobile/src/lib/utils.ts b/apps/mobile/src/lib/utils.ts index a4fddf2731..24f7c9decb 100644 --- a/apps/mobile/src/lib/utils.ts +++ b/apps/mobile/src/lib/utils.ts @@ -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, formatDate, 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, +}; From e8c753b42213dbef5de3a3641ec3d094ca187acc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 13:46:06 +0200 Subject: [PATCH 152/166] simplify(security-agent): reuse PillGroup for dismiss reason picker dismiss-finding-screen.tsx hand-rolled the exact bg-secondary/border-b radio list that PillGroup already renders for the automation and notification severity pickers. Widens PillGroup's value to T | null (the dismiss form starts with no reason selected) and its options to a readonly array so an `as const` literal list can be passed directly. --- .../security-agent/dismiss-finding-screen.tsx | 52 ++++++------------- .../security-agent/settings-pill-group.tsx | 5 +- 2 files changed, 19 insertions(+), 38 deletions(-) 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 9aa1153074..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,31 +1,31 @@ import { useRouter } from 'expo-router'; -import { Check, ShieldOff } 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 { 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; @@ -161,33 +161,13 @@ export function DismissFindingScreen({ scope, findingId }: Readonly - - - Reason - - - {DISMISS_REASONS.map(([value, label], index) => { - const selected = reason === value; - return ( - { - setReason(value); - }} - accessibilityRole="radio" - accessibilityState={{ selected }} - > - {label} - {selected && } - - ); - })} - - + 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 0e63c976a7..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; }>) { From a5e181aab9db68f53ed6fd339895ea452db0fdc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 13:48:32 +0200 Subject: [PATCH 153/166] simplify(security-agent): delete advanceSettingsBaseline, inline the spread advanceSettingsBaseline(baseline, patch) was { ...baseline, ...patch } with a JSDoc comment and its own test block. Inlines the spread at all five settings-screen call sites and drops the function and its tests. --- .../analysis-settings-screen.tsx | 3 +-- .../automation-settings-screen.tsx | 7 ++----- .../notification-settings-screen.tsx | 3 +-- .../repository-settings-screen.tsx | 3 +-- .../security-agent/sla-settings-screen.tsx | 3 +-- .../src/security-agent/settings.test.ts | 18 ------------------ .../app-shared/src/security-agent/settings.ts | 12 ------------ 7 files changed, 6 insertions(+), 43 deletions(-) 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 08200c9d53..99e6af89bb 100644 --- a/apps/mobile/src/components/security-agent/analysis-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/analysis-settings-screen.tsx @@ -1,5 +1,4 @@ import { - advanceSettingsBaseline, getSettingsDirtyState, isPersonalSecurityScope, } from '@kilocode/app-shared/security-agent'; @@ -103,7 +102,7 @@ export function AnalysisSettingsScreen({ scope }: Readonly<{ scope: string }>) { const handleSave = async () => { await save.mutateAsync(patch); - initialConfigRef.current = advanceSettingsBaseline(initialConfigRef.current, patch); + initialConfigRef.current = { ...initialConfigRef.current, ...patch }; }; const { onBack, skipNextGuardRef } = useSettingsBackGuard({ dirty, valid, onSave: handleSave }); 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 2d97d1a264..c04410c529 100644 --- a/apps/mobile/src/components/security-agent/automation-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/automation-settings-screen.tsx @@ -1,7 +1,4 @@ -import { - advanceSettingsBaseline, - getSettingsDirtyState, -} from '@kilocode/app-shared/security-agent'; +import { getSettingsDirtyState } from '@kilocode/app-shared/security-agent'; import { useEffect, useRef, useState } from 'react'; import { View } from 'react-native'; import { toast } from 'sonner-native'; @@ -130,7 +127,7 @@ export function AutomationSettingsScreen({ scope }: Readonly<{ scope: string }>) const handleSave = async () => { const result = await save.mutateAsync(patch); - initialConfigRef.current = advanceSettingsBaseline(initialConfigRef.current, patch); + initialConfigRef.current = { ...initialConfigRef.current, ...patch }; if (result.existingFindingsQueuedCount) { toast.success( `${result.existingFindingsQueuedCount} existing finding${result.existingFindingsQueuedCount === 1 ? '' : 's'} queued for analysis.` diff --git a/apps/mobile/src/components/security-agent/notification-settings-screen.tsx b/apps/mobile/src/components/security-agent/notification-settings-screen.tsx index 4f92cc9f0d..f79145149a 100644 --- a/apps/mobile/src/components/security-agent/notification-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/notification-settings-screen.tsx @@ -1,5 +1,4 @@ import { - advanceSettingsBaseline, getSettingsDirtyState, isPersonalSecurityScope, isValidDayCount, @@ -136,7 +135,7 @@ export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string } const handleSave = async () => { await save.mutateAsync(patch); - initialConfigRef.current = advanceSettingsBaseline(initialConfigRef.current, patch); + initialConfigRef.current = { ...initialConfigRef.current, ...patch }; }; const { onBack, skipNextGuardRef } = useSettingsBackGuard({ dirty, valid, onSave: handleSave }); diff --git a/apps/mobile/src/components/security-agent/repository-settings-screen.tsx b/apps/mobile/src/components/security-agent/repository-settings-screen.tsx index e1b638ba79..1fc97d943a 100644 --- a/apps/mobile/src/components/security-agent/repository-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/repository-settings-screen.tsx @@ -1,5 +1,4 @@ import { - advanceSettingsBaseline, getSettingsDirtyState, isPersonalSecurityScope, } from '@kilocode/app-shared/security-agent'; @@ -85,7 +84,7 @@ export function RepositorySettingsScreen({ scope }: Readonly<{ scope: string }>) const handleSave = async () => { await save.mutateAsync(patch); - initialConfigRef.current = advanceSettingsBaseline(initialConfigRef.current, patch); + initialConfigRef.current = { ...initialConfigRef.current, ...patch }; }; const { onBack, skipNextGuardRef } = useSettingsBackGuard({ dirty, valid, onSave: handleSave }); 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 c80d47c06d..4d9e44aea7 100644 --- a/apps/mobile/src/components/security-agent/sla-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/sla-settings-screen.tsx @@ -1,5 +1,4 @@ import { - advanceSettingsBaseline, getSettingsDirtyState, isValidDayCount, parseDayCount, @@ -195,7 +194,7 @@ export function SlaSettingsScreen({ scope }: Readonly<{ scope: string }>) { const handleSave = async () => { await save.mutateAsync(patch); - initialConfigRef.current = advanceSettingsBaseline(initialConfigRef.current, patch); + initialConfigRef.current = { ...initialConfigRef.current, ...patch }; }; const { onBack, skipNextGuardRef } = useSettingsBackGuard({ dirty, valid, onSave: handleSave }); diff --git a/packages/app-shared/src/security-agent/settings.test.ts b/packages/app-shared/src/security-agent/settings.test.ts index d132e334fa..b65975d51e 100644 --- a/packages/app-shared/src/security-agent/settings.test.ts +++ b/packages/app-shared/src/security-agent/settings.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { - advanceSettingsBaseline, canManageSecurityAgent, getSecurityAgentAuditUrl, getSecurityRepositoriesInScope, @@ -88,23 +87,6 @@ describe('getSettingsDirtyState', () => { }); }); -describe('advanceSettingsBaseline', () => { - const baseline = { slaCriticalDays: 15, selectedRepositoryIds: [1, 2] }; - - it('a saved patch advances the baseline, so the same patch reads clean afterwards', () => { - const patch = { slaCriticalDays: 20 }; - const advanced = advanceSettingsBaseline(baseline, patch); - expect(getSettingsDirtyState(advanced, patch, true)).toBe('clean'); - }); - - it('a failed save must not advance the baseline, so the same patch still reads dirty', () => { - const patch = { slaCriticalDays: 20 }; - // Baseline intentionally left untouched here, mirroring what a screen - // should do when save.mutateAsync rejects. - expect(getSettingsDirtyState(baseline, patch, true)).toBe('dirty-valid'); - }); -}); - describe('getSettingsBackGuardOptions', () => { it('offers no options when clean, so back navigates immediately', () => { expect(getSettingsBackGuardOptions('clean')).toEqual([]); diff --git a/packages/app-shared/src/security-agent/settings.ts b/packages/app-shared/src/security-agent/settings.ts index ce70014832..a2910eedd3 100644 --- a/packages/app-shared/src/security-agent/settings.ts +++ b/packages/app-shared/src/security-agent/settings.ts @@ -89,18 +89,6 @@ export function getSettingsDirtyState>( return valid ? 'dirty-valid' : 'dirty-invalid'; } -/** - * Advances a settings screen's dirty-comparison baseline after a successful - * save, so `getSettingsDirtyState(advanced, patch, valid)` reads 'clean' - * without waiting for the screen to re-hydrate from a refetched query. - */ -export function advanceSettingsBaseline>( - baseline: Partial, - patch: Partial -): Partial { - return { ...baseline, ...patch }; -} - type SettingsBackGuardOption = 'save' | 'discard' | 'keep-editing'; /** From 688b61ea17335d8cda0a45682e161d7bc5edee58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 13:55:45 +0200 Subject: [PATCH 154/166] simplify: share PlatformErrorScreen between code-reviewer and security-agent Moves platform-error-screen.tsx out of components/code-reviewer/ (a neutral location, since security-agent now imports it too) and adds optional eyebrow/errorTitle/message/variant/isRetrying so it covers both features' full-screen load-failure shape instead of each screen hand-rolling its own ScreenHeader+QueryError wrapper. Replaces the duplicated wrapper at 7 security-agent sites (analysis, automation, notification, repository, sla, settings-overview, scope-entry) with PlatformErrorScreen, preserving each site's existing icon/title/message via explicit variant/errorTitle props. scope-entry-screen.tsx's local ScopeEntryError component is deleted as it's now just this shared one. --- .../src/components/{code-reviewer => }/platform-error-screen.tsx | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/mobile/src/components/{code-reviewer => }/platform-error-screen.tsx (100%) diff --git a/apps/mobile/src/components/code-reviewer/platform-error-screen.tsx b/apps/mobile/src/components/platform-error-screen.tsx similarity index 100% rename from apps/mobile/src/components/code-reviewer/platform-error-screen.tsx rename to apps/mobile/src/components/platform-error-screen.tsx From 1303365e06a131cb289eb46a8f9cbcda24e92a84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 13:55:55 +0200 Subject: [PATCH 155/166] simplify: content half of the PlatformErrorScreen sharing Rest of the previous commit's change: generalizes PlatformErrorScreen with optional eyebrow/errorTitle/message/variant/isRetrying and points the 7 security-agent full-screen error states (plus code-reviewer's existing 3 call sites) at it instead of each hand-rolling its own ScreenHeader+QueryError wrapper. --- .../platform-overview-screen.tsx | 2 +- .../src/components/platform-error-screen.tsx | 37 ++++++++++++++++--- apps/mobile/src/components/query-error.tsx | 2 +- .../analysis-settings-screen.tsx | 19 ++++------ .../automation-settings-screen.tsx | 20 ++++------ .../notification-settings-screen.tsx | 20 ++++------ .../repository-settings-screen.tsx | 19 ++++------ .../security-agent/scope-entry-screen.tsx | 27 ++------------ .../settings-overview-screen.tsx | 20 ++++------ .../security-agent/sla-settings-screen.tsx | 17 ++++----- 10 files changed, 86 insertions(+), 97 deletions(-) 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 38194764d2..f75a479ff7 100644 --- a/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx @@ -5,12 +5,12 @@ import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanim import { openModelPicker } from '@/components/agents/model-selector'; import { BitbucketOverview } from '@/components/code-reviewer/bitbucket-overview'; -import { PlatformErrorScreen } from '@/components/code-reviewer/platform-error-screen'; 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'; diff --git a/apps/mobile/src/components/platform-error-screen.tsx b/apps/mobile/src/components/platform-error-screen.tsx index e2ce57e310..02aefebe46 100644 --- a/apps/mobile/src/components/platform-error-screen.tsx +++ b/apps/mobile/src/components/platform-error-screen.tsx @@ -1,20 +1,47 @@ import { View } from 'react-native'; -import { QueryError } from '@/components/query-error'; +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, -}: Readonly<{ title: string; onRetry: () => void; isRetrying: boolean }>) { + 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/query-error.tsx b/apps/mobile/src/components/query-error.tsx index f1f8aee651..e42a807710 100644 --- a/apps/mobile/src/components/query-error.tsx +++ b/apps/mobile/src/components/query-error.tsx @@ -11,7 +11,7 @@ import { EmptyState } from '@/components/empty-state'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; -type QueryErrorVariant = 'neutral' | 'offline' | 'permission' | 'not-found' | 'server'; +export type QueryErrorVariant = 'neutral' | 'offline' | 'permission' | 'not-found' | 'server'; const VARIANT_META: Record< QueryErrorVariant, 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 99e6af89bb..20ecdf4f5c 100644 --- a/apps/mobile/src/components/security-agent/analysis-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/analysis-settings-screen.tsx @@ -9,12 +9,13 @@ 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, useTabBarBottomPadding } from '@/components/tab-screen'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { useAvailableModels } from '@/lib/hooks/use-available-models'; import { useSecurityAgentSettingsRedirect, @@ -52,7 +53,6 @@ function AnalysisSettingsSkeleton() { export function AnalysisSettingsScreen({ scope }: Readonly<{ scope: string }>) { const router = useRouter(); - const paddingBottom = useTabBarBottomPadding(); const canManage = useSecurityAgentEditCapability(scope); const config = useSecurityAgentConfig(scope); const save = useSaveSecurityAgentConfig(scope); @@ -109,15 +109,12 @@ export function AnalysisSettingsScreen({ scope }: Readonly<{ scope: string }>) { if (config.isError && !config.data) { return ( - - - - void config.refetch()} - /> - - + void config.refetch()} + /> ); } if (config.isLoading || !config.data) { 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 c04410c529..0570415032 100644 --- a/apps/mobile/src/components/security-agent/automation-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/automation-settings-screen.tsx @@ -6,11 +6,11 @@ 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, useTabBarBottomPadding } from '@/components/tab-screen'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { useSecurityAgentSettingsRedirect, useSettingsBackGuard, @@ -55,7 +55,6 @@ function AutomationSettingsSkeleton() { } export function AutomationSettingsScreen({ scope }: Readonly<{ scope: string }>) { - const paddingBottom = useTabBarBottomPadding(); const canManage = useSecurityAgentEditCapability(scope); const config = useSecurityAgentConfig(scope); const save = useSaveSecurityAgentConfig(scope); @@ -139,15 +138,12 @@ export function AutomationSettingsScreen({ scope }: Readonly<{ scope: string }>) if (config.isError && !config.data) { return ( - - - - void config.refetch()} - /> - - + void config.refetch()} + /> ); } if (config.isLoading || !config.data) { diff --git a/apps/mobile/src/components/security-agent/notification-settings-screen.tsx b/apps/mobile/src/components/security-agent/notification-settings-screen.tsx index f79145149a..2c2d18caaa 100644 --- a/apps/mobile/src/components/security-agent/notification-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/notification-settings-screen.tsx @@ -10,11 +10,11 @@ import { TextInput, View } from 'react-native'; import { PillGroup } from '@/components/security-agent/settings-pill-group'; import { SettingsSaveButton } from '@/components/security-agent/settings-save-button'; import { ToggleRow } from '@/components/security-agent/settings-toggle-row'; +import { PlatformErrorScreen } from '@/components/platform-error-screen'; import { ScreenHeader } from '@/components/screen-header'; -import { QueryError } from '@/components/query-error'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; -import { TabScreenScrollView, useTabBarBottomPadding } from '@/components/tab-screen'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { useSecurityAgentSettingsRedirect, useSettingsBackGuard, @@ -57,7 +57,6 @@ function NotificationSettingsSkeleton() { export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string }>) { const colors = useThemeColors(); - const paddingBottom = useTabBarBottomPadding(); const canManage = useSecurityAgentEditCapability(scope); const config = useSecurityAgentConfig(scope); const save = useSaveSecurityAgentConfig(scope); @@ -142,15 +141,12 @@ export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string } if (config.isError && !config.data) { return ( - - - - void config.refetch()} - /> - - + void config.refetch()} + /> ); } if (config.isLoading || !config.data) { diff --git a/apps/mobile/src/components/security-agent/repository-settings-screen.tsx b/apps/mobile/src/components/security-agent/repository-settings-screen.tsx index 1fc97d943a..9c601d71c9 100644 --- a/apps/mobile/src/components/security-agent/repository-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/repository-settings-screen.tsx @@ -9,12 +9,13 @@ import { Pressable, View } from 'react-native'; import { SettingsSaveButton } from '@/components/security-agent/settings-save-button'; import { EmptyState } from '@/components/empty-state'; +import { PlatformErrorScreen } from '@/components/platform-error-screen'; import { ScreenHeader } from '@/components/screen-header'; 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 { TabScreenScrollView, useTabBarBottomPadding } from '@/components/tab-screen'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { getGitHubIntegrationUrl } from '@/lib/agent-github-integration'; import { WEB_BASE_URL } from '@/lib/config'; import { openExternalUrl } from '@/lib/external-link'; @@ -50,7 +51,6 @@ function RepositorySettingsSkeleton() { export function RepositorySettingsScreen({ scope }: Readonly<{ scope: string }>) { const colors = useThemeColors(); - const paddingBottom = useTabBarBottomPadding(); const canManage = useSecurityAgentEditCapability(scope); const config = useSecurityAgentConfig(scope); const repositories = useSecurityAgentRepositories(scope); @@ -91,15 +91,12 @@ export function RepositorySettingsScreen({ scope }: Readonly<{ scope: string }>) if (config.isError && !config.data) { return ( - - - - void config.refetch()} - /> - - + void config.refetch()} + /> ); } if (config.isLoading || !config.data) { 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 1b46167623..b4464c7b77 100644 --- a/apps/mobile/src/components/security-agent/scope-entry-screen.tsx +++ b/apps/mobile/src/components/security-agent/scope-entry-screen.tsx @@ -7,11 +7,10 @@ import { MoreHorizontal } from 'lucide-react-native'; import { useEffect } from 'react'; import { Pressable, View } from 'react-native'; -import { QueryError } from '@/components/query-error'; +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 { useTabBarBottomPadding } from '@/components/tab-screen'; import { Skeleton } from '@/components/ui/skeleton'; import { getGitHubIntegrationUrl } from '@/lib/agent-github-integration'; import { WEB_BASE_URL } from '@/lib/config'; @@ -44,26 +43,6 @@ function ScopeEntrySkeleton() { ); } -function ScopeEntryError({ - onRetry, - isRetrying, -}: Readonly<{ onRetry: () => void; isRetrying: boolean }>) { - const paddingBottom = useTabBarBottomPadding(); - return ( - - - - - - - ); -} - export function ScopeEntryScreen({ scope }: Readonly<{ scope: string }>) { const router = useRouter(); const colors = useThemeColors(); @@ -101,7 +80,9 @@ export function ScopeEntryScreen({ scope }: Readonly<{ scope: string }>) { if (isError) { return ( - { void permission.refetch(); void config.refetch(); 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 1ed11fe230..a2dee4d67d 100644 --- a/apps/mobile/src/components/security-agent/settings-overview-screen.tsx +++ b/apps/mobile/src/components/security-agent/settings-overview-screen.tsx @@ -5,12 +5,12 @@ import { Bell, Clock, Cpu, FolderGit2, MoreHorizontal, Zap } from 'lucide-react- import { useEffect, useRef } from 'react'; import { Pressable, Switch, View } from 'react-native'; +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, useTabBarBottomPadding } from '@/components/tab-screen'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { WEB_BASE_URL } from '@/lib/config'; import { openExternalUrl } from '@/lib/external-link'; import { @@ -39,7 +39,6 @@ function SettingsOverviewSkeleton() { export function SettingsOverviewScreen({ scope }: Readonly<{ scope: string }>) { const router = useRouter(); const colors = useThemeColors(); - const paddingBottom = useTabBarBottomPadding(); const config = useSecurityAgentConfig(scope); const canManage = useSecurityAgentEditCapability(scope); const setEnabled = useSetSecurityAgentEnabled(scope); @@ -62,15 +61,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) { 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 4d9e44aea7..c85a82e077 100644 --- a/apps/mobile/src/components/security-agent/sla-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/sla-settings-screen.tsx @@ -8,11 +8,11 @@ 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, useTabBarBottomPadding } from '@/components/tab-screen'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { useSecurityAgentSettingsRedirect, useSettingsBackGuard, @@ -116,7 +116,6 @@ function SlaDayRow({ } export function SlaSettingsScreen({ scope }: Readonly<{ scope: string }>) { - const paddingBottom = useTabBarBottomPadding(); const canManage = useSecurityAgentEditCapability(scope); const config = useSecurityAgentConfig(scope); const save = useSaveSecurityAgentConfig(scope); @@ -201,12 +200,12 @@ export function SlaSettingsScreen({ scope }: Readonly<{ scope: string }>) { if (config.isError && !config.data) { return ( - - - - void config.refetch()} /> - - + void config.refetch()} + /> ); } if (config.isLoading || !config.data) { From 4fd1a56710079eb9a8b69578e50f266cd789469d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 13:59:38 +0200 Subject: [PATCH 156/166] simplify: extract RepoToggleRow, adopt ChoiceRow at remaining hand-rolled rows RepoToggleRow (new, components/repo-toggle-row.tsx) replaces the copy-pasted repo checkbox row (Lock + name + Check, identical classes and toggle logic) in code-reviewer's repos.tsx and security-agent's repository-settings-screen.tsx; it's built on ChoiceRow via a new optional `children` override (for the icon-prefixed label ChoiceRow's plain label/description text can't express). Also adopts ChoiceRow at the remaining hand-rolled Pressable+Check rows this PR left duplicating its boilerplate: the repository-mode radio picker in both repos.tsx and repository-settings-screen.tsx, and finding-filter-modal's FilterOptionRow (kept as a thin wrapper using ChoiceRow's children override, since its default label rendering's `capitalize` class would mangle arbitrary repo full names). --- .../[scope]/[platform]/(edit)/repos.tsx | 57 +++++-------------- .../mobile/src/components/repo-toggle-row.tsx | 43 ++++++++++++++ .../security-agent/finding-filter-modal.tsx | 17 ++---- .../repository-settings-screen.tsx | 57 +++++-------------- apps/mobile/src/components/ui/choice-row.tsx | 25 +++++--- 5 files changed, 91 insertions(+), 108 deletions(-) create mode 100644 apps/mobile/src/components/repo-toggle-row.tsx 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 index 4295847a9a..ff5f2114d7 100644 --- 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 @@ -1,12 +1,13 @@ -import * as Haptics from 'expo-haptics'; import { useLocalSearchParams } from 'expo-router'; -import { Check, FolderGit2, Lock } from 'lucide-react-native'; -import { Pressable, View } from 'react-native'; +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'; @@ -23,13 +24,10 @@ import { useReviewConfigCacheReader, useSaveReviewConfig, } from '@/lib/hooks/use-code-reviewer'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { getBitbucketIntegrationUrl, getGitLabIntegrationUrl } from '@/lib/integration-urls'; -import { cn } from '@/lib/utils'; export default function ReposRoute() { const { scope, platform } = useLocalSearchParams<{ scope: string; platform: ReviewerPlatform }>(); - const colors = useThemeColors(); const { data } = useReviewConfig(scope, platform); const save = useSaveReviewConfig(scope, platform); const readConfig = useReviewConfigCacheReader(scope, platform); @@ -106,12 +104,10 @@ export default function ReposRoute() { const emptyStateCopy = emptyStateCopyByPlatform[platform]; 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. @@ -128,24 +124,16 @@ export default function ReposRoute() { {capabilities.selectionModePicker && (['all', 'selected'] as const).map(option => ( - { setMode(option); }} - > - - {option === 'all' ? 'All repositories' : 'Selected repositories'} - - - + /> ))} {(!capabilities.selectionModePicker || mode === 'selected') && ( @@ -211,33 +199,16 @@ export default function ReposRoute() { )} {repoRows.map(repo => ( - { toggleRepo(repo.id); }} - > - - {repo.private ? : null} - - {repo.fullName} - - - - + /> ))} )} 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/security-agent/finding-filter-modal.tsx b/apps/mobile/src/components/security-agent/finding-filter-modal.tsx index 6f49804b89..c02b132bc4 100644 --- a/apps/mobile/src/components/security-agent/finding-filter-modal.tsx +++ b/apps/mobile/src/components/security-agent/finding-filter-modal.tsx @@ -8,13 +8,12 @@ import { selectSecurityFindingOutcome, selectSecurityFindingStatus, } from '@kilocode/app-shared/security-agent'; -import { Check } from 'lucide-react-native'; import { useState } from 'react'; -import { Pressable, ScrollView, View } from 'react-native'; +import { ScrollView, View } from 'react-native'; import { Button } from '@/components/ui/button'; +import { ChoiceRow } from '@/components/ui/choice-row'; import { Text } from '@/components/ui/text'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; const STATUS_OPTIONS: { value: SecurityFindingStatusFilter; label: string }[] = [ { value: 'open', label: 'Open' }, @@ -75,20 +74,12 @@ type FilterOptionRowProps = { }; function FilterOptionRow({ label, isSelected, onPress }: Readonly) { - const colors = useThemeColors(); - return ( - + {label} - {isSelected && } - + ); } diff --git a/apps/mobile/src/components/security-agent/repository-settings-screen.tsx b/apps/mobile/src/components/security-agent/repository-settings-screen.tsx index 9c601d71c9..c1475f7dc1 100644 --- a/apps/mobile/src/components/security-agent/repository-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/repository-settings-screen.tsx @@ -2,17 +2,18 @@ import { getSettingsDirtyState, isPersonalSecurityScope, } from '@kilocode/app-shared/security-agent'; -import * as Haptics from 'expo-haptics'; -import { Check, FolderGit2, Lock } from 'lucide-react-native'; +import { FolderGit2 } from 'lucide-react-native'; import { useEffect, useRef, useState } from 'react'; -import { Pressable, View } from 'react-native'; +import { View } from 'react-native'; import { SettingsSaveButton } from '@/components/security-agent/settings-save-button'; import { EmptyState } from '@/components/empty-state'; import { PlatformErrorScreen } from '@/components/platform-error-screen'; +import { RepoToggleRow } from '@/components/repo-toggle-row'; import { ScreenHeader } from '@/components/screen-header'; import { QueryError } from '@/components/query-error'; import { Button } from '@/components/ui/button'; +import { ChoiceRow } from '@/components/ui/choice-row'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { TabScreenScrollView } from '@/components/tab-screen'; @@ -29,9 +30,7 @@ import { useSecurityAgentEditCapability, useSecurityAgentRepositories, } from '@/lib/hooks/use-security-agent'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { type SecurityAgentConfig } from '@/lib/security-agent'; -import { cn } from '@/lib/utils'; type RepositorySelectionMode = SecurityAgentConfig['repositorySelectionMode']; @@ -50,7 +49,6 @@ function RepositorySettingsSkeleton() { } export function RepositorySettingsScreen({ scope }: Readonly<{ scope: string }>) { - const colors = useThemeColors(); const canManage = useSecurityAgentEditCapability(scope); const config = useSecurityAgentConfig(scope); const repositories = useSecurityAgentRepositories(scope); @@ -107,12 +105,10 @@ export function RepositorySettingsScreen({ scope }: Readonly<{ scope: string }>) } const setModeOption = (option: RepositorySelectionMode) => { - void Haptics.selectionAsync(); setMode(option); }; const toggleRepo = (id: number) => { - void Haptics.selectionAsync(); setSelectedIds(current => current.includes(id) ? current.filter(existing => existing !== id) : [...current, id] ); @@ -142,24 +138,16 @@ export function RepositorySettingsScreen({ scope }: Readonly<{ scope: string }>) )} {(['all', 'selected'] as const).map(option => ( - { setModeOption(option); }} - accessibilityRole="radio" - accessibilityState={{ selected: mode === option, disabled: !canManage }} - > - - {option === 'all' ? 'All repositories' : 'Selected repositories'} - - - + /> ))} {mode === 'selected' && ( @@ -209,33 +197,16 @@ export function RepositorySettingsScreen({ scope }: Readonly<{ scope: string }>) {!repositories.isLoading && !repositories.isError && (repositories.data ?? []).map(repo => ( - { toggleRepo(repo.id); }} - accessibilityRole="checkbox" - accessibilityState={{ - checked: selectedIds.includes(repo.id), - disabled: !canManage, - }} - > - - {repo.private ? : null} - - {repo.fullName} - - - - + /> ))} {!repositories.isLoading && !repositories.isError && diff --git a/apps/mobile/src/components/ui/choice-row.tsx b/apps/mobile/src/components/ui/choice-row.tsx index c25adb94b0..93bdf060b3 100644 --- a/apps/mobile/src/components/ui/choice-row.tsx +++ b/apps/mobile/src/components/ui/choice-row.tsx @@ -1,5 +1,6 @@ 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'; @@ -7,7 +8,8 @@ import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { cn } from '@/lib/utils'; type ChoiceRowProps = { - label: string; + /** Ignored when `children` is given. */ + label?: string; description?: string; selected: boolean; onPress: () => void; @@ -18,6 +20,8 @@ type ChoiceRowProps = { 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; }; /** @@ -33,6 +37,7 @@ export function ChoiceRow({ busy, multi, className, + children, }: Readonly) { const colors = useThemeColors(); @@ -51,14 +56,16 @@ export function ChoiceRow({ accessibilityRole={multi ? 'checkbox' : 'radio'} accessibilityState={{ checked: selected, disabled: Boolean(disabled), busy }} > - - {label} - {description ? ( - - {description} - - ) : null} - + {children ?? ( + + {label} + {description ? ( + + {description} + + ) : null} + + )} ); From 7828e80f57cadb7c8ad8bcaf7938a976843541c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 14:01:59 +0200 Subject: [PATCH 157/166] simplify(security-agent): extract AuditReportButton, open audit report directly dashboard-screen.tsx's "More actions" button opened a one-item action sheet (View audit report / Cancel) for what scope-entry-screen.tsx and settings-overview-screen.tsx already do as a direct MoreHorizontal press. Extracts the shared Pressable into AuditReportButton and points all three at it, dropping dashboard's action sheet plumbing for this action (its repo-filter action sheet is unaffected). Sanctioned behavior change: tapping the button on the dashboard now opens the audit report directly instead of via the action sheet. --- .../security-agent/audit-report-button.tsx | 31 +++++++++++++++++++ .../security-agent/dashboard-screen.tsx | 26 ++-------------- .../security-agent/scope-entry-screen.tsx | 27 +++------------- .../settings-overview-screen.tsx | 25 +++------------ 4 files changed, 42 insertions(+), 67 deletions(-) create mode 100644 apps/mobile/src/components/security-agent/audit-report-button.tsx 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/dashboard-screen.tsx b/apps/mobile/src/components/security-agent/dashboard-screen.tsx index 8e09ab2a6c..bfb5bf56ba 100644 --- a/apps/mobile/src/components/security-agent/dashboard-screen.tsx +++ b/apps/mobile/src/components/security-agent/dashboard-screen.tsx @@ -1,25 +1,23 @@ 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, 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 { TabScreenScrollView } from '@/components/tab-screen'; -import { WEB_BASE_URL } from '@/lib/config'; import { useSecurityAgentConfig, useSecurityAgentDashboardStats, @@ -104,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} } /> 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 b4464c7b77..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,12 +1,9 @@ -import { - getSecurityAgentAuditUrl, - isPersonalSecurityScope, -} from '@kilocode/app-shared/security-agent'; +import { isPersonalSecurityScope } from '@kilocode/app-shared/security-agent'; import { useRouter } from 'expo-router'; -import { MoreHorizontal } from 'lucide-react-native'; 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'; @@ -14,14 +11,12 @@ import { SecurityAgentSetup } from '@/components/security-agent/security-agent-s import { Skeleton } from '@/components/ui/skeleton'; import { getGitHubIntegrationUrl } from '@/lib/agent-github-integration'; import { WEB_BASE_URL } from '@/lib/config'; -import { openExternalUrl } from '@/lib/external-link'; import { useSecurityAgentCapability, useSecurityAgentConfig, useSecurityAgentPermissionStatus, useSecurityAgentRepositories, } from '@/lib/hooks/use-security-agent'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { getSecurityAgentPath } from '@/lib/security-agent'; function ScopeEntrySkeleton() { @@ -45,7 +40,6 @@ function ScopeEntrySkeleton() { export function ScopeEntryScreen({ scope }: Readonly<{ scope: string }>) { const router = useRouter(); - const colors = useThemeColors(); const permission = useSecurityAgentPermissionStatus(scope); const config = useSecurityAgentConfig(scope); const repositories = useSecurityAgentRepositories(scope); @@ -97,20 +91,7 @@ export function ScopeEntryScreen({ scope }: Readonly<{ scope: string }>) { // 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 ? ( - { - 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" - > - - - ) : null; + const auditAction = capability.canManage ? : null; if (!hasIntegration) { return ( 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 a2dee4d67d..451201ea0d 100644 --- a/apps/mobile/src/components/security-agent/settings-overview-screen.tsx +++ b/apps/mobile/src/components/security-agent/settings-overview-screen.tsx @@ -1,25 +1,22 @@ -import { getSecurityAgentAuditUrl } from '@kilocode/app-shared/security-agent'; import * as Haptics from 'expo-haptics'; import { useRouter } from 'expo-router'; -import { Bell, Clock, Cpu, FolderGit2, MoreHorizontal, Zap } from 'lucide-react-native'; +import { Bell, Clock, Cpu, FolderGit2, Zap } from 'lucide-react-native'; import { useEffect, useRef } from 'react'; -import { Pressable, 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 { 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 { WEB_BASE_URL } from '@/lib/config'; -import { openExternalUrl } from '@/lib/external-link'; import { useSecurityAgentConfig, useSecurityAgentEditCapability, useSetSecurityAgentEnabled, useTrackSecurityAgentInteraction, } from '@/lib/hooks/use-security-agent'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { getSecurityAgentPath } from '@/lib/security-agent'; import { capitalize } from '@/lib/utils'; @@ -38,7 +35,6 @@ function SettingsOverviewSkeleton() { export function SettingsOverviewScreen({ scope }: Readonly<{ scope: string }>) { const router = useRouter(); - const colors = useThemeColors(); const config = useSecurityAgentConfig(scope); const canManage = useSecurityAgentEditCapability(scope); const setEnabled = useSetSecurityAgentEnabled(scope); @@ -103,20 +99,7 @@ export function SettingsOverviewScreen({ scope }: Readonly<{ scope: string }>) { // 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 ? ( - { - 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" - > - - - ) : null; + const auditAction = canManage ? : null; return ( From 7c9e46b1ca240da1cb7edbc409977cbf508a7a44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 14:03:14 +0200 Subject: [PATCH 158/166] simplify(security-agent): collapse SLA day-count state into one record sla-settings-screen.tsx had four parallel useState(Number.NaN) hooks plus hand-built setters/values Records used only to route SlaDayRow's per-severity value/onChange through the four fields by key. Collapses to one useState>, so the day-row list reads and writes slaDays[row.key] directly instead of indirecting through the Records. --- .../security-agent/sla-settings-screen.tsx | 61 ++++++++----------- 1 file changed, 26 insertions(+), 35 deletions(-) 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 c85a82e077..5ec7926205 100644 --- a/apps/mobile/src/components/security-agent/sla-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/sla-settings-screen.tsx @@ -122,10 +122,12 @@ export function SlaSettingsScreen({ scope }: Readonly<{ scope: string }>) { 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>({}); @@ -141,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); @@ -167,25 +171,25 @@ export function SlaSettingsScreen({ scope }: Readonly<{ scope: string }>) { // 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 = { - critical: isValidDayCount(slaCriticalDays), - high: isValidDayCount(slaHighDays), - medium: isValidDayCount(slaMediumDays), - low: isValidDayCount(slaLowDays), + 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: daysValid.critical - ? slaCriticalDays - : (initialConfigRef.current.slaCriticalDays ?? slaCriticalDays), + ? slaDays.critical + : (initialConfigRef.current.slaCriticalDays ?? slaDays.critical), slaHighDays: daysValid.high - ? slaHighDays - : (initialConfigRef.current.slaHighDays ?? slaHighDays), + ? slaDays.high + : (initialConfigRef.current.slaHighDays ?? slaDays.high), slaMediumDays: daysValid.medium - ? slaMediumDays - : (initialConfigRef.current.slaMediumDays ?? slaMediumDays), - slaLowDays: daysValid.low ? slaLowDays : (initialConfigRef.current.slaLowDays ?? slaLowDays), + ? slaDays.medium + : (initialConfigRef.current.slaMediumDays ?? slaDays.medium), + slaLowDays: daysValid.low ? slaDays.low : (initialConfigRef.current.slaLowDays ?? slaDays.low), }; const dirty = hydratedRef.current && @@ -215,19 +219,6 @@ 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 ( ) { 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 })); }} /> ))} From 41d072bb8fa93709cc97101dbc54777504b79251 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 14:07:46 +0200 Subject: [PATCH 159/166] simplify(security-agent): collapse capability hooks onto useSecurityAgentCapability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useSecurityAgentEditCapability was just useSecurityAgentCapability(scope) .canManage, and useSecurityAgentOrgRole's only caller (finding-detail- screen.tsx) took the raw role only to re-run canManageSecurityAgent(scope, role) — re-deriving what useSecurityAgentCapability already returns. Deletes both hooks, keeping useSecurityAgentCapability as the one query; updates all 7 callers. Also drops the now-unused OrganizationRole re-export from lib/security-agent.ts (knip-flagged after the callers that used it were removed). --- .../security-agent/analysis-settings-screen.tsx | 4 ++-- .../automation-settings-screen.tsx | 4 ++-- .../security-agent/dashboard-screen.tsx | 4 ++-- .../security-agent/finding-detail-screen.tsx | 7 +++---- .../notification-settings-screen.tsx | 4 ++-- .../repository-settings-screen.tsx | 4 ++-- .../security-agent/settings-overview-screen.tsx | 4 ++-- .../security-agent/sla-settings-screen.tsx | 4 ++-- apps/mobile/src/lib/hooks/use-security-agent.ts | 16 +++------------- apps/mobile/src/lib/security-agent.ts | 2 -- 10 files changed, 20 insertions(+), 33 deletions(-) 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 20ecdf4f5c..b2faccda4d 100644 --- a/apps/mobile/src/components/security-agent/analysis-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/analysis-settings-screen.tsx @@ -23,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'; @@ -53,7 +53,7 @@ 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 { 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 0570415032..77bebe74dd 100644 --- a/apps/mobile/src/components/security-agent/automation-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/automation-settings-screen.tsx @@ -17,8 +17,8 @@ import { } 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'; @@ -55,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); diff --git a/apps/mobile/src/components/security-agent/dashboard-screen.tsx b/apps/mobile/src/components/security-agent/dashboard-screen.tsx index bfb5bf56ba..e2042a2803 100644 --- a/apps/mobile/src/components/security-agent/dashboard-screen.tsx +++ b/apps/mobile/src/components/security-agent/dashboard-screen.tsx @@ -19,9 +19,9 @@ import { SpinningIcon } from '@/components/ui/spinning-icon'; import { Text } from '@/components/ui/text'; import { TabScreenScrollView } from '@/components/tab-screen'; import { + useSecurityAgentCapability, useSecurityAgentConfig, useSecurityAgentDashboardStats, - useSecurityAgentEditCapability, useSecurityAgentLastSyncTime, useSecurityAgentRepositories, useTriggerSecuritySync, @@ -48,7 +48,7 @@ export function DashboardScreen({ scope }: Readonly<{ scope: string }>) { 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; diff --git a/apps/mobile/src/components/security-agent/finding-detail-screen.tsx b/apps/mobile/src/components/security-agent/finding-detail-screen.tsx index 12fc8fe389..f6c5b61f31 100644 --- a/apps/mobile/src/components/security-agent/finding-detail-screen.tsx +++ b/apps/mobile/src/components/security-agent/finding-detail-screen.tsx @@ -1,4 +1,3 @@ -import { canManageSecurityAgent } from '@kilocode/app-shared/security-agent'; import { useRouter } from 'expo-router'; import { Ban, ShieldOff } from 'lucide-react-native'; import { useEffect, useRef, useState } from 'react'; @@ -14,7 +13,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { TabScreenScrollView, useTabBarBottomPadding } from '@/components/tab-screen'; import { - useSecurityAgentOrgRole, + useSecurityAgentCapability, useTrackSecurityAgentInteraction, } from '@/lib/hooks/use-security-agent'; import { useSecurityAnalysis, useSecurityFinding } from '@/lib/hooks/use-security-findings'; @@ -54,7 +53,7 @@ export function FindingDetailScreen({ scope, findingId }: Readonly diff --git a/apps/mobile/src/components/security-agent/notification-settings-screen.tsx b/apps/mobile/src/components/security-agent/notification-settings-screen.tsx index 2c2d18caaa..45c05a9a04 100644 --- a/apps/mobile/src/components/security-agent/notification-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/notification-settings-screen.tsx @@ -21,8 +21,8 @@ import { } 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'; @@ -57,7 +57,7 @@ function NotificationSettingsSkeleton() { export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string }>) { const colors = useThemeColors(); - const canManage = useSecurityAgentEditCapability(scope); + const canManage = useSecurityAgentCapability(scope).canManage; const config = useSecurityAgentConfig(scope); const save = useSaveSecurityAgentConfig(scope); const trackInteraction = useTrackSecurityAgentInteraction(scope); diff --git a/apps/mobile/src/components/security-agent/repository-settings-screen.tsx b/apps/mobile/src/components/security-agent/repository-settings-screen.tsx index c1475f7dc1..b3f1c0d31c 100644 --- a/apps/mobile/src/components/security-agent/repository-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/repository-settings-screen.tsx @@ -26,8 +26,8 @@ import { } from '@/lib/hooks/use-settings-back-guard'; import { useSaveSecurityAgentConfig, + useSecurityAgentCapability, useSecurityAgentConfig, - useSecurityAgentEditCapability, useSecurityAgentRepositories, } from '@/lib/hooks/use-security-agent'; import { type SecurityAgentConfig } from '@/lib/security-agent'; @@ -49,7 +49,7 @@ function RepositorySettingsSkeleton() { } export function RepositorySettingsScreen({ scope }: Readonly<{ scope: string }>) { - const canManage = useSecurityAgentEditCapability(scope); + const canManage = useSecurityAgentCapability(scope).canManage; const config = useSecurityAgentConfig(scope); const repositories = useSecurityAgentRepositories(scope); const save = useSaveSecurityAgentConfig(scope); 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 451201ea0d..eb92ad9462 100644 --- a/apps/mobile/src/components/security-agent/settings-overview-screen.tsx +++ b/apps/mobile/src/components/security-agent/settings-overview-screen.tsx @@ -12,8 +12,8 @@ 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'; @@ -36,7 +36,7 @@ function SettingsOverviewSkeleton() { 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); 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 5ec7926205..22556336a5 100644 --- a/apps/mobile/src/components/security-agent/sla-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/sla-settings-screen.tsx @@ -19,8 +19,8 @@ import { } 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'; @@ -116,7 +116,7 @@ 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); diff --git a/apps/mobile/src/lib/hooks/use-security-agent.ts b/apps/mobile/src/lib/hooks/use-security-agent.ts index 0a440beb80..59c0592785 100644 --- a/apps/mobile/src/lib/hooks/use-security-agent.ts +++ b/apps/mobile/src/lib/hooks/use-security-agent.ts @@ -4,7 +4,7 @@ import { } from '@kilocode/app-shared/security-agent'; import { useQuery, type UseQueryResult } from '@tanstack/react-query'; -import { type OrganizationRole, type SecurityAgentConfig } from '@/lib/security-agent'; +import { type SecurityAgentConfig } from '@/lib/security-agent'; import { useTRPC } from '@/lib/trpc'; // Mutation hooks (save config, set enabled, trigger sync, track interaction) @@ -105,9 +105,8 @@ export function useSecurityAgentLastSyncTime(scope: string, repoFullName?: strin // // 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. Consumers that must tell those -// apart use `useSecurityAgentOrgRoleQuery`/`useSecurityAgentCapability` -// below; existing boolean-only callers are unchanged. +// 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 isPersonal = isPersonalSecurityScope(scope); @@ -137,15 +136,6 @@ function useSecurityAgentOrgRoleQuery(scope: string) { }; } -export function useSecurityAgentOrgRole(scope: string): OrganizationRole | undefined { - return useSecurityAgentOrgRoleQuery(scope).role; -} - -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. 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}`; From da836e8dc8705fb213dd1caf7468db0122647ead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 14:08:46 +0200 Subject: [PATCH 160/166] simplify(code-reviewer): fold review-detail-screen's duplicate error branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The !data.success branch rendered byte-identically to the transient "could not load" branch above it (same title/variant/onRetry) — folds into one if (!data || !data.success) after the permanent-error-code check, which still returns early with the not-found/permission variant untouched. --- .../code-reviewer/review-detail-screen.tsx | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) 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 42ed687d62..e3bd9ed854 100644 --- a/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx @@ -75,7 +75,7 @@ export function ReviewDetailScreen({ // 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) { + if (!data || !data.success) { const errorCode = isError ? error.data?.code : undefined; if (errorCode === 'NOT_FOUND' || errorCode === 'FORBIDDEN' || errorCode === 'UNAUTHORIZED') { return ( @@ -102,22 +102,6 @@ export function ReviewDetailScreen({ ); } - if (!data.success) { - return ( - - - - void refetch()} - isRetrying={isFetching} - /> - - - ); - } - const { review, tokenUsage } = data; const meta = statusMeta(review.status); const canCancel = isCancellableReviewStatus(review.status); From 8bc149d0475c7c51aa5cdb4039435c55286c1183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 14:18:01 +0200 Subject: [PATCH 161/166] fix(code-reviewer): restore Code Reviewer eyebrow on platform error screens The pre-sharing platform-error-screen hardcoded eyebrow="Code Reviewer" on its ScreenHeader; the shared PlatformErrorScreen defaults eyebrow to undefined, so platform-overview-screen's three error states (permission, provider-state, config) silently lost it. Pass it explicitly at all three call sites. --- .../src/components/code-reviewer/platform-overview-screen.tsx | 3 +++ 1 file changed, 3 insertions(+) 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 f75a479ff7..78a6a46348 100644 --- a/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx @@ -55,6 +55,7 @@ export function PlatformOverviewScreen({ return ( { permission.refetch(); }} @@ -89,6 +90,7 @@ export function PlatformOverviewScreen({ return ( { providerState.refetch(); }} @@ -109,6 +111,7 @@ export function PlatformOverviewScreen({ return ( { void config.refetch(); }} From e534489220cdf2727111a7e0094e743d14c6dbc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 14:38:03 +0200 Subject: [PATCH 162/166] simplify(mobile): collapse org boundary, purchase-error ownership, sentry consent chain OrganizationBoundary now calls useOrgBoundary() itself and takes an optional title, collapsing all 7 call sites to a single line. Purchase/restore error suppression drops the hand-threaded suppressToast option in favor of a mount counter (useInlinePurchaseErrorOwnership) that the subscription screen registers once. FormField owns blur-validation via a validate prop, removing ~4 copy-pasted touched-field state blocks. sentry-consent drops the manual gate/Promise-constructor plumbing for a plain await chain with identical serialization/no-reject/un-mark-on-failure semantics. Also: extracted AddCreditsRow and InlineRetry to de-duplicate zero-balance and retry UI, removed force-update's redundant Copy URL button, folded poll-response's 1xx/3xx fallthrough into the error arm, dropped formatDate's no-op options object, and trimmed validator/utils tests down to the product rules and Hermes-specific behavior they actually cover. --- .../mobile/src/components/add-credits-row.tsx | 31 ++++++ .../src/components/force-update-screen.tsx | 10 -- .../kilo-pass-subscription-screen.tsx | 8 +- .../kilo-pass/restore-purchases-button.tsx | 4 +- .../src/components/notifications-card.tsx | 49 ++++---- .../organization/credit-activity-screen.tsx | 22 +--- .../components/organization/hub-screen.tsx | 49 ++------ .../organization/invite-member-sheet.tsx | 37 +----- .../organization/invoices-screen.tsx | 22 +--- .../organization/low-balance-alert-sheet.tsx | 56 +--------- .../low-balance-alert-validators.test.ts | 48 +------- .../organization/member-limit-sheet.tsx | 30 +---- .../member-limit-validators.test.ts | 29 +---- .../organization/member-limit-validators.ts | 2 +- .../organization/members-screen.tsx | 23 +--- .../organization/organization-boundary.tsx | 85 +++++++------- .../src/components/profile-credits-card.tsx | 36 +++--- apps/mobile/src/components/ui/form-field.tsx | 45 +++++++- .../mobile/src/lib/auth/poll-response.test.ts | 11 +- apps/mobile/src/lib/auth/poll-response.ts | 8 +- .../use-store-kilo-pass-purchase.test.tsx | 94 +++++++--------- .../kilo-pass/use-store-kilo-pass-purchase.ts | 105 +++++++++--------- apps/mobile/src/lib/sentry-consent.ts | 27 ++--- packages/app-shared/src/utils.test.ts | 60 +--------- packages/app-shared/src/utils.ts | 6 +- 25 files changed, 303 insertions(+), 594 deletions(-) create mode 100644 apps/mobile/src/components/add-credits-row.tsx 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/force-update-screen.tsx b/apps/mobile/src/components/force-update-screen.tsx index 18f3e4f07e..223d3dcc14 100644 --- a/apps/mobile/src/components/force-update-screen.tsx +++ b/apps/mobile/src/components/force-update-screen.tsx @@ -1,8 +1,6 @@ -import * as Clipboard from 'expo-clipboard'; import { Download } from 'lucide-react-native'; import { useState } from 'react'; import { Linking, Platform, View } from 'react-native'; -import { toast } from 'sonner-native'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; @@ -27,11 +25,6 @@ export function ForceUpdateScreen() { } }; - const copyStoreUrl = async () => { - await Clipboard.setStringAsync(STORE_URL); - toast.success('Store link copied'); - }; - return ( @@ -60,9 +53,6 @@ export function ForceUpdateScreen() { > Open in browser - )} 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 a3ebd89fc9..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 @@ -19,7 +19,10 @@ 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'; @@ -41,6 +44,7 @@ export function KiloPassSubscriptionScreen() { 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 } @@ -60,8 +64,6 @@ export function KiloPassSubscriptionScreen() { onCompleted: () => { ensureProfileAfterKiloPassPurchase(router); }, - // This screen owns the inline error banner — don't double up with a toast. - suppressToast: true, }); }; 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 f179e5cf18..3778ba558c 100644 --- a/apps/mobile/src/components/kilo-pass/restore-purchases-button.tsx +++ b/apps/mobile/src/components/kilo-pass/restore-purchases-button.tsx @@ -28,9 +28,7 @@ export function RestorePurchasesButton({ onResult }: Readonly { void Haptics.selectionAsync(); void (async () => { - // A caller that renders its own feedback (onResult) also owns the error banner — - // suppress the hook's toast so failures aren't reported twice. - const result = await restorePurchases({ suppressToast: onResult !== undefined }); + const result = await restorePurchases(); if (onResult) { onResult(result); return; diff --git a/apps/mobile/src/components/notifications-card.tsx b/apps/mobile/src/components/notifications-card.tsx index f4c2c2abe7..181da71e9f 100644 --- a/apps/mobile/src/components/notifications-card.tsx +++ b/apps/mobile/src/components/notifications-card.tsx @@ -22,6 +22,22 @@ import { useTRPC } from '@/lib/trpc'; const permissionQueryKey = ['notificationPermission']; const deviceTokenQueryKey = ['devicePushToken']; +type InlineRetryProps = Readonly<{ label: string; color: string; onPress: () => void }>; + +function InlineRetry({ label, color, onPress }: InlineRetryProps) { + return ( + + + Retry + + ); +} + export function NotificationsCard() { const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -65,6 +81,7 @@ export function NotificationsCard() { enabled: isAuthenticated, }); const chatTokensError = deviceTokenError || pushTokensError; + const chatLoading = permissionLoading || tokensLoading || deviceTokenLoading; const pushTokensQueryKey = trpc.user.getMyPushTokens.queryOptions().queryKey; const serverRegistered = @@ -211,15 +228,11 @@ export function NotificationsCard() { Notifications {permissionLoading && } {!permissionLoading && permissionError && ( - void refetchPermission()} - accessibilityRole="button" - accessibilityLabel="Retry checking notification permission" - > - - Retry - + /> )} {!permissionLoading && !permissionError && ( <> @@ -242,24 +255,18 @@ export function NotificationsCard() { > Chat messages - {(permissionLoading || tokensLoading || deviceTokenLoading) && ( - - )} - {!permissionLoading && !tokensLoading && !deviceTokenLoading && chatTokensError && ( - } + {!chatLoading && chatTokensError && ( + { void refetchDeviceToken(); void refetchPushTokens(); }} - accessibilityRole="button" - accessibilityLabel="Retry loading chat message notification status" - > - - Retry - + /> )} - {!permissionLoading && !tokensLoading && !deviceTokenLoading && !chatTokensError && ( + {!chatLoading && !chatTokensError && ( <> {chatTogglePending && } } export function OrganizationCreditActivityScreen() { - const { - organizationId, - org, - isResolving, - isError: isOrgListError, - isFetching: isOrgListFetching, - refetch: refetchOrgList, - } = useOrgBoundary(); + const { organizationId, org, isResolving } = useOrgBoundary(); const query = useOrgCreditTransactions(organizationId); const paddingBottom = useTabBarBottomPadding(); if (isResolving || organizationId == null || org == null) { - return ( - - - - - ); + return ; } const isLoading = query.isLoading; diff --git a/apps/mobile/src/components/organization/hub-screen.tsx b/apps/mobile/src/components/organization/hub-screen.tsx index 38705ce61a..6eb7b2c9f9 100644 --- a/apps/mobile/src/components/organization/hub-screen.tsx +++ b/apps/mobile/src/components/organization/hub-screen.tsx @@ -6,17 +6,16 @@ import { useState } from 'react'; import { Pressable, View } from 'react-native'; import Animated, { FadeIn } from 'react-native-reanimated'; +import { AddCreditsRow } from '@/components/add-credits-row'; import { OrganizationBoundary } from '@/components/organization/organization-boundary'; import { OrgUsageStats } from '@/components/organization/org-usage-stats'; import { RenameModal } from '@/components/rename-modal'; import { ScreenHeader } from '@/components/screen-header'; -import { Button } from '@/components/ui/button'; import { ConfigureRow } from '@/components/ui/configure-row'; import { KvRow } from '@/components/ui/kv-row'; import { Text } from '@/components/ui/text'; import { TabScreenScrollView } from '@/components/tab-screen'; import { WEB_BASE_URL } from '@/lib/config'; -import { openExternalUrl } from '@/lib/external-link'; import { useOrganizationMutations } from '@/lib/hooks/use-organization-mutations'; import { isMoneyRole, @@ -28,32 +27,13 @@ import { useThemeColors } from '@/lib/hooks/use-theme-colors'; export function OrganizationHubScreen() { const router = useRouter(); const colors = useThemeColors(); - const { - organizationId, - role, - org, - isResolving, - isError: isOrgListError, - isFetching: isOrgListFetching, - refetch: refetchOrgList, - } = useOrgBoundary(); + const { organizationId, role, org, isResolving } = useOrgBoundary(); const orgWithMembers = useOrgWithMembers(organizationId); const mutations = useOrganizationMutations(organizationId ?? ''); const [renameVisible, setRenameVisible] = useState(false); if (isResolving || organizationId == null || org == null) { - return ( - - - - - ); + return ; } const showMoney = isMoneyRole(role); @@ -92,25 +72,10 @@ export function OrganizationHubScreen() { )} {showMoney && org.balance === 0 && ( - - - Add credits to keep usage running. - - - + )} diff --git a/apps/mobile/src/components/organization/invite-member-sheet.tsx b/apps/mobile/src/components/organization/invite-member-sheet.tsx index bc61d24649..18bd0ce27e 100644 --- a/apps/mobile/src/components/organization/invite-member-sheet.tsx +++ b/apps/mobile/src/components/organization/invite-member-sheet.tsx @@ -23,19 +23,10 @@ const EMAIL_ERROR = 'Enter a valid email address'; export function InviteMemberSheet() { const router = useRouter(); const colors = useThemeColors(); - const { - organizationId, - role: myRole, - org, - isResolving, - isError: isOrgListError, - isFetching: isOrgListFetching, - refetch: refetchOrgList, - } = useOrgBoundary(); + const { organizationId, role: myRole, org, isResolving } = useOrgBoundary(); const mutations = useOrganizationMutations(organizationId ?? ''); const emailRef = useRef(''); const [canSubmit, setCanSubmit] = useState(false); - const [emailError, setEmailError] = useState(null); const isBillingManager = myRole === 'billing_manager'; const [role, setRole] = useState('member'); @@ -48,16 +39,7 @@ export function InviteMemberSheet() { ); } if (organizationId == null || org == null) { - return ( - - - - ); + return ; } if (!isMoneyRole(myRole)) { return ; @@ -65,10 +47,6 @@ export function InviteMemberSheet() { const onSubmit = () => { const email = emailRef.current.trim().toLowerCase(); - if (!EMAIL_PATTERN.test(email)) { - setEmailError(EMAIL_ERROR); - return; - } mutations.invite.mutate( { email, role: isBillingManager ? 'member' : role }, { @@ -100,17 +78,10 @@ export function InviteMemberSheet() { autoCapitalize="none" autoCorrect={false} autoFocus - error={emailError ?? undefined} + validate={value => (EMAIL_PATTERN.test(value.trim()) ? null : EMAIL_ERROR)} onChangeText={value => { emailRef.current = value; - const valid = EMAIL_PATTERN.test(value.trim()); - setCanSubmit(valid); - if (emailError) { - setEmailError(valid ? null : EMAIL_ERROR); - } - }} - onBlur={() => { - setEmailError(EMAIL_PATTERN.test(emailRef.current.trim()) ? null : EMAIL_ERROR); + setCanSubmit(EMAIL_PATTERN.test(value.trim())); }} /> diff --git a/apps/mobile/src/components/organization/invoices-screen.tsx b/apps/mobile/src/components/organization/invoices-screen.tsx index 9a81bb28ab..1cdafcb218 100644 --- a/apps/mobile/src/components/organization/invoices-screen.tsx +++ b/apps/mobile/src/components/organization/invoices-screen.tsx @@ -70,30 +70,12 @@ function InvoiceRow({ invoice }: Readonly<{ invoice: OrgInvoice }>) { } export function OrganizationInvoicesScreen() { - const { - organizationId, - org, - isResolving, - isError: isOrgListError, - isFetching: isOrgListFetching, - refetch: refetchOrgList, - } = useOrgBoundary(); + const { organizationId, org, isResolving } = useOrgBoundary(); const query = useOrgInvoices(organizationId); const paddingBottom = useTabBarBottomPadding(); if (isResolving || organizationId == null || org == null) { - return ( - - - - - ); + return ; } const isLoading = query.isLoading; 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 2a2d30309c..bcc5bed8ca 100644 --- a/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx +++ b/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx @@ -50,8 +50,6 @@ function LowBalanceAlertForm({ organizationId, settings }: LowBalanceAlertFormPr !enabled || (thresholdError(thresholdRef.current) == null && emailsError(emailsRef.current) == null) ); - const [thresholdFieldError, setThresholdFieldError] = useState(null); - const [emailsFieldError, setEmailsFieldError] = useState(null); const revalidate = (nextEnabled: boolean, thresholdValue: string, emailsValue: string) => { setCanSave( @@ -104,16 +102,10 @@ function LowBalanceAlertForm({ organizationId, settings }: LowBalanceAlertFormPr placeholder="10.00" keyboardType="decimal-pad" defaultValue={thresholdRef.current || undefined} - error={thresholdFieldError ?? undefined} + validate={thresholdError} onChangeText={value => { thresholdRef.current = value; revalidate(enabled, value, emailsRef.current); - if (thresholdFieldError) { - setThresholdFieldError(thresholdError(value)); - } - }} - onBlur={() => { - setThresholdFieldError(thresholdError(thresholdRef.current)); }} /> @@ -126,16 +118,10 @@ function LowBalanceAlertForm({ organizationId, settings }: LowBalanceAlertFormPr autoCapitalize="none" autoCorrect={false} defaultValue={emailsRef.current || undefined} - error={emailsFieldError ?? undefined} + validate={emailsError} onChangeText={value => { emailsRef.current = value; revalidate(enabled, thresholdRef.current, value); - if (emailsFieldError) { - setEmailsFieldError(emailsError(value)); - } - }} - onBlur={() => { - setEmailsFieldError(emailsError(emailsRef.current)); }} /> @@ -163,18 +149,10 @@ function LowBalanceAlertForm({ organizationId, settings }: LowBalanceAlertFormPr } export function LowBalanceAlertSheet() { - const { - organizationId, - role, - org, - isResolving, - isError: isOrgListError, - isFetching: isOrgListFetching, - refetch: refetchOrgList, - } = useOrgBoundary(); + const { organizationId, role, org, isResolving } = useOrgBoundary(); const orgWithMembers = useOrgWithMembers(organizationId); - if (isResolving) { + if (isResolving || orgWithMembers.isLoading) { return ( @@ -191,16 +169,7 @@ export function LowBalanceAlertSheet() { ); } if (organizationId == null || org == null) { - return ( - - - - ); + return ; } if (!isMoneyRole(role)) { return ; @@ -222,21 +191,6 @@ export function LowBalanceAlertSheet() { placement="top" /> ); - } else { - body = ( - <> - - - - - - - - - - - - ); } return ( 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 index 75816366e9..f9acce80e1 100644 --- a/apps/mobile/src/components/organization/low-balance-alert-validators.test.ts +++ b/apps/mobile/src/components/organization/low-balance-alert-validators.test.ts @@ -1,53 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { - emailsError, - parseEmails, - parseThreshold, - thresholdError, -} from '@/components/organization/low-balance-alert-validators'; - -describe('parseThreshold', () => { - it('parses a positive number', () => { - expect(parseThreshold('10')).toBe(10); - }); - - it('rejects blank, zero, negative, and non-numeric input', () => { - expect(parseThreshold('')).toBeNull(); - expect(parseThreshold('0')).toBeNull(); - expect(parseThreshold('-5')).toBeNull(); - expect(parseThreshold('abc')).toBeNull(); - }); -}); - -describe('thresholdError', () => { - it('returns null for a valid amount', () => { - expect(thresholdError('10')).toBeNull(); - }); - - it('returns an error message for an invalid amount', () => { - expect(thresholdError('')).not.toBeNull(); - expect(thresholdError('0')).not.toBeNull(); - }); -}); - -describe('parseEmails', () => { - it('splits, trims, and drops empty entries', () => { - expect(parseEmails('a@x.com, b@x.com ,, ')).toEqual(['a@x.com', 'b@x.com']); - }); -}); +import { emailsError } from '@/components/organization/low-balance-alert-validators'; describe('emailsError', () => { - it('returns null when every email is valid', () => { - expect(emailsError('a@x.com, b@x.com')).toBeNull(); - }); - - it('returns an error when the list is empty', () => { - expect(emailsError('')).not.toBeNull(); - expect(emailsError(' , ')).not.toBeNull(); - }); - - it('returns an error when any email is malformed', () => { + 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/member-limit-sheet.tsx b/apps/mobile/src/components/organization/member-limit-sheet.tsx index 4d6b6bec64..8f0263158b 100644 --- a/apps/mobile/src/components/organization/member-limit-sheet.tsx +++ b/apps/mobile/src/components/organization/member-limit-sheet.tsx @@ -34,7 +34,6 @@ function MemberLimitForm({ memberId, organizationId, member }: MemberLimitFormPr const limitRef = useRef(currentLimit != null ? String(currentLimit) : ''); const [canSave, setCanSave] = useState(limitError(limitRef.current) == null); - const [fieldError, setFieldError] = useState(null); const onSaved = () => { void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); @@ -61,16 +60,10 @@ function MemberLimitForm({ memberId, organizationId, member }: MemberLimitFormPr placeholder="No limit" keyboardType="decimal-pad" defaultValue={currentLimit != null ? String(currentLimit) : undefined} - error={fieldError ?? undefined} + validate={limitError} onChangeText={value => { limitRef.current = value; setCanSave(limitError(value) == null); - if (fieldError) { - setFieldError(limitError(value)); - } - }} - onBlur={() => { - setFieldError(limitError(limitRef.current)); }} /> @@ -96,15 +89,7 @@ function MemberLimitForm({ memberId, organizationId, member }: MemberLimitFormPr } export function MemberLimitSheet({ memberId }: Readonly<{ memberId: string }>) { - const { - organizationId, - role, - org, - isResolving, - isError: isOrgListError, - isFetching: isOrgListFetching, - refetch: refetchOrgList, - } = useOrgBoundary(); + 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 @@ -124,16 +109,7 @@ export function MemberLimitSheet({ memberId }: Readonly<{ memberId: string }>) { } if (organizationId == null || org == null) { - return ( - - - - ); + return ; } if (role !== 'owner') { diff --git a/apps/mobile/src/components/organization/member-limit-validators.test.ts b/apps/mobile/src/components/organization/member-limit-validators.test.ts index 0bff163553..aa46657975 100644 --- a/apps/mobile/src/components/organization/member-limit-validators.test.ts +++ b/apps/mobile/src/components/organization/member-limit-validators.test.ts @@ -1,37 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { - limitError, - MAX_DAILY_LIMIT_USD, - parseLimit, -} from '@/components/organization/member-limit-validators'; +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(); }); - - it('accepts an amount within range', () => { - expect(limitError('10')).toBeNull(); - expect(limitError(String(MAX_DAILY_LIMIT_USD))).toBeNull(); - expect(limitError('0')).toBeNull(); - }); - - it('rejects out-of-range or non-numeric input', () => { - expect(limitError('-1')).not.toBeNull(); - expect(limitError(String(MAX_DAILY_LIMIT_USD + 1))).not.toBeNull(); - expect(limitError('abc')).not.toBeNull(); - }); -}); - -describe('parseLimit', () => { - it('parses a numeric string', () => { - expect(parseLimit('42')).toBe(42); - }); - - it('parses blank as null', () => { - expect(parseLimit('')).toBeNull(); - expect(parseLimit(' ')).toBeNull(); - }); }); diff --git a/apps/mobile/src/components/organization/member-limit-validators.ts b/apps/mobile/src/components/organization/member-limit-validators.ts index c383f0edbe..a01b045833 100644 --- a/apps/mobile/src/components/organization/member-limit-validators.ts +++ b/apps/mobile/src/components/organization/member-limit-validators.ts @@ -1,4 +1,4 @@ -export const MAX_DAILY_LIMIT_USD = 2000; +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'; diff --git a/apps/mobile/src/components/organization/members-screen.tsx b/apps/mobile/src/components/organization/members-screen.tsx index 1dd8de9bf0..2bf6ba591b 100644 --- a/apps/mobile/src/components/organization/members-screen.tsx +++ b/apps/mobile/src/components/organization/members-screen.tsx @@ -59,31 +59,12 @@ function MemberRowSkeleton({ last }: Readonly<{ last?: boolean }>) { export function OrganizationMembersScreen() { const router = useRouter(); const colors = useThemeColors(); - const { - organizationId, - role, - org, - isResolving, - isError: isOrgListError, - isFetching: isOrgListFetching, - refetch: refetchOrgList, - } = useOrgBoundary(); + const { organizationId, role, org, isResolving } = useOrgBoundary(); const orgWithMembers = useOrgWithMembers(organizationId); const { userId: currentUserId } = useCurrentUserId(); if (isResolving || organizationId == null || org == null) { - return ( - - - - - ); + return ; } const isLoading = orgWithMembers.isLoading; diff --git a/apps/mobile/src/components/organization/organization-boundary.tsx b/apps/mobile/src/components/organization/organization-boundary.tsx index c534cf4aaf..c001186c07 100644 --- a/apps/mobile/src/components/organization/organization-boundary.tsx +++ b/apps/mobile/src/components/organization/organization-boundary.tsx @@ -1,24 +1,21 @@ 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<{ - /** Identity/org-list is still resolving — show a brief in-progress state instead of the empty state. */ - isResolving?: boolean; - /** The underlying `organizations.list` query failed — a retryable fetch failure, not a stale org selection. */ - isError?: boolean; - isFetching?: boolean; - refetch?: () => unknown; - /** Null when no organization has ever been selected — gets distinct copy from "selection no longer resolves". */ - organizationId?: string | null; + /** Full-screen callers pass a title for the `ScreenHeader`; sheets omit it and own no chrome. */ + title?: string; }>; /** @@ -32,59 +29,59 @@ type OrganizationBoundaryProps = Readonly<{ * 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. - * Callers own their own chrome (`ScreenHeader` for full screens, nothing for - * form sheets) — this only renders the content. + * 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({ - isResolving, - isError, - isFetching, - refetch, - organizationId, -}: OrganizationBoundaryProps = {}) { +export function OrganizationBoundary({ title }: OrganizationBoundaryProps = {}) { const router = useRouter(); const colors = useThemeColors(); + const { organizationId, isResolving, isError, isFetching, refetch } = useOrgBoundary(); + let content: ReactNode = null; if (isResolving) { - return ( + content = ( ); - } - - if (isError) { - return ( + } else if (isError) { + content = ( void refetch() : undefined} + onRetry={() => void refetch()} isRetrying={isFetching} /> ); + } else { + const noSelection = organizationId == null; + content = ( + { + router.replace(PROFILE_HREF); + }} + > + Back to profile + + } + /> + ); } - const noSelection = organizationId == null; - return ( - { - router.replace(PROFILE_HREF); - }} - > - Back to profile - - } - /> + + {title != null && } + {content} + ); } diff --git a/apps/mobile/src/components/profile-credits-card.tsx b/apps/mobile/src/components/profile-credits-card.tsx index 1e9678a585..94aaeb7f3a 100644 --- a/apps/mobile/src/components/profile-credits-card.tsx +++ b/apps/mobile/src/components/profile-credits-card.tsx @@ -6,12 +6,11 @@ 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 { 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 { isMoneyRole, type OrgListEntry } from '@/lib/hooks/use-organization-queries'; import { useOrganization } from '@/lib/organization-context'; @@ -165,26 +164,19 @@ export function CreditsCard({ enabled, orgs }: Readonly) { {balanceFetching && } )} - {!balanceLoading && !balanceError && balanceDollars === 0 && ( - - - {canShowZeroBalanceCta - ? 'Add credits to keep usage running.' - : 'Your credit balance is empty. Credits are managed outside the iOS app for this account.'} - - {canShowZeroBalanceCta && ( - - )} - - )} + {!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/ui/form-field.tsx b/apps/mobile/src/components/ui/form-field.tsx index 36644293ab..1d9fa6242b 100644 --- a/apps/mobile/src/components/ui/form-field.tsx +++ b/apps/mobile/src/components/ui/form-field.tsx @@ -1,3 +1,4 @@ +import { useRef, useState } from 'react'; import { TextInput, type TextInputProps, View } from 'react-native'; import { Text } from '@/components/ui/text'; @@ -9,6 +10,12 @@ type FormFieldProps = Omit & 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; }; /** @@ -16,8 +23,22 @@ type FormFieldProps = Omit & * 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, ...props }: Readonly) { +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 ( @@ -25,21 +46,35 @@ function FormField({ label, error, disabled, className, ref, ...props }: Readonl { + 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', - error && 'border-destructive', + displayedError && 'border-destructive', disabled && 'opacity-50', className )} /> - {error ? ( + {displayedError ? ( - {error} + {displayedError} ) : null} diff --git a/apps/mobile/src/lib/auth/poll-response.test.ts b/apps/mobile/src/lib/auth/poll-response.test.ts index b399881b27..efc7672715 100644 --- a/apps/mobile/src/lib/auth/poll-response.test.ts +++ b/apps/mobile/src/lib/auth/poll-response.test.ts @@ -25,10 +25,13 @@ describe('classifyPollResponse', () => { }); }); - it.each([400, 401])('treats %i as a terminal error', httpStatus => { - const outcome = classifyPollResponse(httpStatus); - expect(outcome.status).toBe('error'); - }); + 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 index 89295baf09..a82b64b6ef 100644 --- a/apps/mobile/src/lib/auth/poll-response.ts +++ b/apps/mobile/src/lib/auth/poll-response.ts @@ -25,9 +25,7 @@ export function classifyPollResponse(httpStatus: number): PollOutcome { if (httpStatus === 429 || httpStatus >= 500) { return { status: 'retry' }; } - // Any other 4xx (400, 401, ...) is not something retrying will fix. - if (httpStatus >= 400) { - return { status: 'error', message: 'Sign-in failed. Please try again.' }; - } - return { status: 'pending' }; + // 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/kilo-pass/use-store-kilo-pass-purchase.test.tsx b/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.test.tsx index a436401a50..525dbdd741 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 @@ -8,6 +8,7 @@ import { createAppStoreKiloPassPurchaseActions, resetPurchaseErrorToastDedup, StoreKiloPassPurchaseProvider, + useInlinePurchaseErrorOwnership, } from './use-store-kilo-pass-purchase'; import { type AppStoreKiloPassProduct } from './store-products'; @@ -102,11 +103,9 @@ type StoreKiloPassPurchaseContextValue = { appStoreOwnershipPreflight: 'owned-by-another-account' | null; purchase: ( product: AppStoreKiloPassProduct, - options?: { onCompleted?: () => void; suppressToast?: boolean } + options?: { onCompleted?: () => void } ) => Promise; - restorePurchases: (options?: { - suppressToast?: boolean; - }) => Promise<'restored' | 'empty' | 'failed'>; + restorePurchases: () => Promise<'restored' | 'empty' | 'failed'>; isPending: boolean; isRestoringPurchases: boolean; errorMessage: string | null; @@ -196,6 +195,33 @@ function renderStoreKiloPassPurchaseProvider() { return { render }; } +/** Mounts `useInlinePurchaseErrorOwnership`, returning an `unmount` that runs its cleanup. */ +function InlineErrorOwnershipHarness() { + 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 { + useInlinePurchaseErrorOwnership(); + } finally { + reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H = + previousDispatcher; + } + + return { + unmount: () => { + cleanup?.(); + }, + }; +} + function ignoreDeferredResolution(_value: unknown) { return undefined; } @@ -353,22 +379,6 @@ describe('createAppStoreKiloPassPurchaseActions', () => { expect(showError).toHaveBeenCalledWith('Could not connect to App Store'); }); - it('tells showError to suppress its toast when the caller owns inline feedback', async () => { - const showError = vi.fn(); - const actions = createActions({ - requestPurchase: vi.fn().mockRejectedValue(new Error('Could not connect to App Store')), - showError: (message, options) => { - showError(message, options); - }, - }); - - await actions.purchase(product, { suppressToast: true }); - - expect(showError).toHaveBeenCalledWith('Could not connect to App Store', { - suppressToast: true, - }); - }); - it('does not show an error when the user cancels the App Store purchase sheet', async () => { const showError = vi.fn(); const actions = createActions({ @@ -774,24 +784,6 @@ describe('createAppStoreKiloPassPurchaseActions', () => { expect(result).toBe('failed'); expect(showError).toHaveBeenCalledWith('Failed to restore purchases. Try again.'); }); - - it('tells showError to suppress its toast when restore is driven by an inline-feedback caller', async () => { - const showError = vi.fn(); - const actions = createActions({ - getAvailablePurchases: vi.fn(), - restorePurchases: vi.fn().mockRejectedValue(new Error('StoreKit unavailable')), - showError: (message, options) => { - showError(message, options); - }, - }); - - const result = await actions.restorePurchases({ suppressToast: true }); - - expect(result).toBe('failed'); - expect(showError).toHaveBeenCalledWith('Failed to restore purchases. Try again.', { - suppressToast: true, - }); - }); }); describe('StoreKiloPassPurchaseProvider', () => { @@ -868,30 +860,24 @@ describe('StoreKiloPassPurchaseProvider', () => { expect(releasedValue.errorMessage).toBe('Untoasted screen check failed'); }); - it('suppresses the purchase-error toast when the subscription screen owns inline feedback', async () => { + it('suppresses the purchase-error toast while a screen owns inline feedback, and resumes once it unmounts', async () => { const provider = renderStoreKiloPassPurchaseProvider(); + const mountInlineErrorOwnershipHarness = InlineErrorOwnershipHarness; + const owner = mountInlineErrorOwnershipHarness(); const initialValue = provider.render(); - await initialValue.purchase(product, { suppressToast: true }); - + await initialValue.purchase(product); mockedIap.handlers?.onPurchaseError(new Error('Inline banner check failed')); - const releasedValue = provider.render(); + const ownedValue = provider.render(); expect(toast.error).not.toHaveBeenCalled(); - expect(releasedValue.errorMessage).toBe('Inline banner check failed'); - }); + expect(ownedValue.errorMessage).toBe('Inline banner check failed'); - it('suppresses the restore-error toast when the caller passes suppressToast', async () => { - mockedIap.restorePurchases.mockRejectedValue(new Error('StoreKit unavailable')); - const provider = renderStoreKiloPassPurchaseProvider(); - - const initialValue = provider.render(); - const result = await initialValue.restorePurchases({ suppressToast: true }); - const releasedValue = provider.render(); + owner.unmount(); + await ownedValue.purchase(product); + mockedIap.handlers?.onPurchaseError(new Error('Untoasted screen check failed')); - expect(result).toBe('failed'); - expect(toast.error).not.toHaveBeenCalled(); - expect(releasedValue.errorMessage).toBe('Failed to restore purchases. Try again.'); + expect(toast.error).toHaveBeenCalledWith('Untoasted screen check failed'); }); it('ignores live StoreKit success for an unknown product', async () => { 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 e288520ef0..37f6ea3adf 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 @@ -72,15 +72,10 @@ type AppStoreKiloPassPurchaseActionsDeps = { invalidateAfterCompletion: () => Promise | void; onPurchaseCompleted?: () => void; setPendingPurchaseCompletedCallback?: (callback: (() => void) | null) => void; - showError: (message: string, options?: ShowErrorOptions) => void; + showError: (message: string) => void; }; -type ShowErrorOptions = { - /** Skip the toast — an inline consumer (e.g. the subscription screen) already owns feedback. */ - suppressToast?: boolean; -}; - -type StoreKiloPassPurchaseOptions = ShowErrorOptions & { +type StoreKiloPassPurchaseOptions = { onCompleted?: () => void; }; @@ -92,7 +87,7 @@ type StoreKiloPassPurchaseContextValue = { product: AppStoreKiloPassProduct, options?: StoreKiloPassPurchaseOptions ) => Promise; - restorePurchases: (options?: ShowErrorOptions) => Promise; + restorePurchases: () => Promise; isPending: boolean; isRestoringPurchases: boolean; /** Last purchase/restore failure message, for screens that render it inline. */ @@ -112,7 +107,22 @@ export function resetPurchaseErrorToastDedup() { lastPurchaseErrorToast = null; } -type PurchaseCompletionOptions = ShowErrorOptions & { +// 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 useInlinePurchaseErrorOwnership() { + useEffect(() => { + inlineErrorOwnerCount += 1; + return () => { + inlineErrorOwnerCount -= 1; + }; + }, []); +} + +type PurchaseCompletionOptions = { invalidateAfterCompletion?: boolean; notifyErrors?: boolean; }; @@ -179,6 +189,10 @@ function getKiloPassPurchaseErrorMessage(error: unknown, fallback: string): stri } function showDedupedPurchaseError(message: string) { + if (inlineErrorOwnerCount > 0) { + return; + } + const now = Date.now(); if ( lastPurchaseErrorToast?.message === message && @@ -219,7 +233,7 @@ export function createAppStoreKiloPassPurchaseActions(deps: AppStoreKiloPassPurc options: PurchaseCompletionOptions ) { if (!result.completed && result.errorMessage && (options.notifyErrors ?? true)) { - deps.showError(result.errorMessage, { suppressToast: options.suppressToast }); + deps.showError(result.errorMessage); } } @@ -309,7 +323,7 @@ export function createAppStoreKiloPassPurchaseActions(deps: AppStoreKiloPassPurc 'Failed to start App Store purchase.' ); if (message) { - deps.showError(message, { suppressToast: options.suppressToast }); + deps.showError(message); } deps.setPendingPurchaseCompletedCallback?.(null); return false; @@ -317,17 +331,13 @@ export function createAppStoreKiloPassPurchaseActions(deps: AppStoreKiloPassPurc }, handlePurchaseSuccess, recoverPurchases, - restorePurchases: async ( - options: ShowErrorOptions = {} - ): Promise => { + restorePurchases: async (): Promise => { try { await deps.restorePurchases(); const availablePurchases = await deps.getAvailablePurchases(); const enabledAppleProductIds = await getEnabledAppleProductIdsForRestore(); if (enabledAppleProductIds.length === 0) { - deps.showError(RESTORE_PURCHASES_ERROR_MESSAGE, { - suppressToast: options.suppressToast, - }); + deps.showError(RESTORE_PURCHASES_ERROR_MESSAGE); return 'failed'; } @@ -341,11 +351,10 @@ export function createAppStoreKiloPassPurchaseActions(deps: AppStoreKiloPassPurc const completedPurchases = await recoverPurchases(kiloPassPurchases, { enabledAppleProductIds, notifyErrors: true, - suppressToast: options.suppressToast, }); return completedPurchases.length > 0 ? 'restored' : 'failed'; } catch { - deps.showError(RESTORE_PURCHASES_ERROR_MESSAGE, { suppressToast: options.suppressToast }); + deps.showError(RESTORE_PURCHASES_ERROR_MESSAGE); return 'failed'; } }, @@ -363,7 +372,7 @@ export function StoreKiloPassPurchaseProvider({ children }: { children: ReactNod }, []); const recoveredPurchaseIdsRef = useRef(new Set()); const recoveryInFlightPurchaseIdsRef = useRef(new Set()); - const activePurchaseRequestRef = useRef<{ sku: string; suppressToast: boolean } | null>(null); + const activePurchaseRequestRef = useRef<{ sku: string } | null>(null); const pendingPurchaseCompletedCallbackRef = useRef<(() => void) | null>(null); const completeAppStorePurchase = useMutation( trpc.kiloPass.completeAppStorePurchase.mutationOptions() @@ -394,16 +403,13 @@ export function StoreKiloPassPurchaseProvider({ children }: { children: ReactNod const actionsRef = useIAP({ onPurchaseError: error => { - const suppressToast = activePurchaseRequestRef.current?.suppressToast ?? false; pendingPurchaseCompletedCallbackRef.current = null; releasePurchaseRequest(); // A null message means the user cancelled — not a failure. const message = getKiloPassPurchaseErrorMessage(error, error.message); if (message) { captureEvent(KILO_PASS_PURCHASE_FAILED_EVENT); - if (!suppressToast) { - showDedupedPurchaseError(message); - } + showDedupedPurchaseError(message); setErrorMessage(message); } }, @@ -417,10 +423,9 @@ export function StoreKiloPassPurchaseProvider({ children }: { children: ReactNod return; } - const suppressToast = activePurchaseRequestRef.current.suppressToast; void (async () => { try { - await actions.handlePurchaseSuccess(purchase, { suppressToast }); + await actions.handlePurchaseSuccess(purchase); } finally { releasePurchaseRequest(); } @@ -472,10 +477,8 @@ export function StoreKiloPassPurchaseProvider({ children }: { children: ReactNod setPendingPurchaseCompletedCallback: onCompleted => { pendingPurchaseCompletedCallbackRef.current = onCompleted; }, - showError: (message, showErrorOptions) => { - if (!showErrorOptions?.suppressToast) { - showDedupedPurchaseError(message); - } + showError: message => { + showDedupedPurchaseError(message); setErrorMessage(message); }, }), @@ -496,10 +499,7 @@ export function StoreKiloPassPurchaseProvider({ children }: { children: ReactNod return; } - activePurchaseRequestRef.current = { - sku: product.appleProductId, - suppressToast: options.suppressToast ?? false, - }; + activePurchaseRequestRef.current = { sku: product.appleProductId }; setIsRequestingPurchase(true); setErrorMessage(null); captureEvent(KILO_PASS_PURCHASE_STARTED_EVENT); @@ -516,28 +516,23 @@ export function StoreKiloPassPurchaseProvider({ children }: { children: ReactNod [actions, completeAppStorePurchase.isPending, releasePurchaseRequest] ); - const restorePurchases = useCallback( - async ( - options: { suppressToast?: boolean } = {} - ): Promise => { - if ( - activePurchaseRequestRef.current || - isRestoringPurchases || - completeAppStorePurchase.isPending - ) { - return 'failed'; - } + const restorePurchases = useCallback(async (): Promise => { + if ( + activePurchaseRequestRef.current || + isRestoringPurchases || + completeAppStorePurchase.isPending + ) { + return 'failed'; + } - setIsRestoringPurchases(true); - setErrorMessage(null); - try { - return await actions.restorePurchases(options); - } finally { - setIsRestoringPurchases(false); - } - }, - [actions, completeAppStorePurchase.isPending, isRestoringPurchases] - ); + setIsRestoringPurchases(true); + setErrorMessage(null); + try { + return await actions.restorePurchases(); + } finally { + setIsRestoringPurchases(false); + } + }, [actions, completeAppStorePurchase.isPending, isRestoringPurchases]); useEffect(() => { if (Platform.OS !== 'ios' || !connected) { diff --git a/apps/mobile/src/lib/sentry-consent.ts b/apps/mobile/src/lib/sentry-consent.ts index c21833c594..3de48fcbbd 100644 --- a/apps/mobile/src/lib/sentry-consent.ts +++ b/apps/mobile/src/lib/sentry-consent.ts @@ -36,10 +36,10 @@ export function sentryOptionsForConsent(consented: boolean): SentryConsentOption // 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 through `lifecycle` +// so every consent transition is close-then-init, chained onto `lifecycle` // so a fast accept → revoke can't interleave close and init. -// The chain promise never rejects (each transition resolves it in `finally`), -// so a failed transition can't poison later ones — they re-attempt their own +// 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; @@ -49,17 +49,14 @@ export async function reinitSentryForConsent( onFailure?: () => void ): Promise { const previous = lifecycle; - const gate: { release?: () => void } = {}; - lifecycle = new Promise(resolve => { - gate.release = resolve; - }); - try { + lifecycle = (async () => { await previous; - await Sentry.close(); - init(consented); - } catch { - onFailure?.(); - } finally { - gate.release?.(); - } + try { + await Sentry.close(); + init(consented); + } catch { + onFailure?.(); + } + })(); + await lifecycle; } diff --git a/packages/app-shared/src/utils.test.ts b/packages/app-shared/src/utils.test.ts index 02c2bd7681..4f6cbafb29 100644 --- a/packages/app-shared/src/utils.test.ts +++ b/packages/app-shared/src/utils.test.ts @@ -1,13 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - firstNonEmpty, - formatCents, - formatDate, - formatDollars, - fromMicrodollars, - parseTimestamp, -} from './utils'; +import { parseTimestamp } from './utils'; describe('parseTimestamp', () => { it('parses a date-only string as UTC midnight', () => { @@ -20,54 +13,3 @@ describe('parseTimestamp', () => { ); }); }); - -describe('firstNonEmpty', () => { - it('returns the first non-empty value', () => { - expect(firstNonEmpty(undefined, null, '', 'a', 'b')).toBe('a'); - }); - - it('returns empty string when none are set', () => { - expect(firstNonEmpty(undefined, null, '')).toBe(''); - }); -}); - -describe('fromMicrodollars', () => { - it('divides by one million', () => { - expect(fromMicrodollars(1_500_000)).toBe(1.5); - }); -}); - -describe('formatDollars', () => { - it('formats as USD currency', () => { - expect(formatDollars(12.5)).toBe('$12.50'); - }); -}); - -describe('formatCents', () => { - it('formats cents as USD currency by default', () => { - expect(formatCents(1250)).toBe('$12.50'); - }); - - it('accepts an explicit currency code', () => { - expect(formatCents(1250, 'eur')).toBe('€12.50'); - }); -}); - -describe('formatDate', () => { - // Compare against the same toLocaleDateString(undefined, ...) call so this - // passes under any CI locale, while still catching format/option drift. - const expected = (date: Date) => - date.toLocaleDateString(undefined, { year: 'numeric', month: 'numeric', day: 'numeric' }); - - it('formats a Date as numeric month/day/year', () => { - // Local-time constructor avoids UTC/local rollover ambiguity across CI timezones. - const date = new Date(2026, 6, 11); - expect(formatDate(date)).toBe(expected(date)); - }); - - it('accepts a parsed backend timestamp', () => { - // Noon UTC keeps the same local calendar day across all realistic CI timezones. - const date = parseTimestamp('2026-01-05 12:00:00+00'); - expect(formatDate(date)).toBe(expected(date)); - }); -}); diff --git a/packages/app-shared/src/utils.ts b/packages/app-shared/src/utils.ts index 3670c4aff7..74421f5990 100644 --- a/packages/app-shared/src/utils.ts +++ b/packages/app-shared/src/utils.ts @@ -54,9 +54,5 @@ export function formatCents(amount: number, currency: string = 'USD') { * timestamps with `parseTimestamp()` first. */ export function formatDate(date: Date): string { - return date.toLocaleDateString(undefined, { - year: 'numeric', - month: 'numeric', - day: 'numeric', - }); + return date.toLocaleDateString(); } From de47868ee52570bf6d7472602f984cbcab748f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 14:51:01 +0200 Subject: [PATCH 163/166] fix(mobile): cover paused-fetch gap in low-balance sheet, reset inline-error owner count in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The low-balance-alert sheet's folded loading branch used isLoading, which is false for an offline paused fetch (no data, no error) — the sheet rendered nothing. Use isPending (no data AND no error) so the paused case shows the skeleton while real errors still reach QueryError; guard with organizationId so a disabled query still falls through to OrganizationBoundary. Also add resetInlinePurchaseErrorOwnership mirroring the toast-dedup reset and wire it into the test beforeEach so a throwing test can't leak ownership into later tests, and drop the pointless call-site alias for the ownership harness (the rules-of-hooks/new-cap dodge now lives inside the helper, matching the existing renderProviderElement pattern). --- .../organization/low-balance-alert-sheet.tsx | 7 ++++++- .../kilo-pass/use-store-kilo-pass-purchase.test.tsx | 12 ++++++++---- .../lib/kilo-pass/use-store-kilo-pass-purchase.ts | 4 ++++ 3 files changed, 18 insertions(+), 5 deletions(-) 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 bcc5bed8ca..38ffea7efe 100644 --- a/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx +++ b/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx @@ -152,7 +152,12 @@ export function LowBalanceAlertSheet() { const { organizationId, role, org, isResolving } = useOrgBoundary(); const orgWithMembers = useOrgWithMembers(organizationId); - if (isResolving || orgWithMembers.isLoading) { + // 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 ( 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 525dbdd741..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,6 +6,7 @@ import { toast } from 'sonner-native'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createAppStoreKiloPassPurchaseActions, + resetInlinePurchaseErrorOwnership, resetPurchaseErrorToastDedup, StoreKiloPassPurchaseProvider, useInlinePurchaseErrorOwnership, @@ -196,7 +197,7 @@ function renderStoreKiloPassPurchaseProvider() { } /** Mounts `useInlinePurchaseErrorOwnership`, returning an `unmount` that runs its cleanup. */ -function InlineErrorOwnershipHarness() { +function mountInlineErrorOwnership() { const reactInternals = React as typeof React & ReactInternals; let cleanup: (() => void) | undefined = undefined; const dispatcher = { @@ -209,7 +210,10 @@ function InlineErrorOwnershipHarness() { 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 { - useInlinePurchaseErrorOwnership(); + // 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; @@ -302,6 +306,7 @@ function createPurchase(overrides: Partial = {}): Purchase { beforeEach(() => { vi.clearAllMocks(); resetPurchaseErrorToastDedup(); + resetInlinePurchaseErrorOwnership(); mockedIap.availablePurchases = []; mockedIap.connected = false; mockedIap.finishTransaction.mockResolvedValue(undefined); @@ -862,8 +867,7 @@ describe('StoreKiloPassPurchaseProvider', () => { it('suppresses the purchase-error toast while a screen owns inline feedback, and resumes once it unmounts', async () => { const provider = renderStoreKiloPassPurchaseProvider(); - const mountInlineErrorOwnershipHarness = InlineErrorOwnershipHarness; - const owner = mountInlineErrorOwnershipHarness(); + const owner = mountInlineErrorOwnership(); const initialValue = provider.render(); await initialValue.purchase(product); 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 37f6ea3adf..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 @@ -113,6 +113,10 @@ export function resetPurchaseErrorToastDedup() { // than one owner is ever mounted at once. let inlineErrorOwnerCount = 0; +export function resetInlinePurchaseErrorOwnership() { + inlineErrorOwnerCount = 0; +} + export function useInlinePurchaseErrorOwnership() { useEffect(() => { inlineErrorOwnerCount += 1; From d045dc3775e8b1784800c58665bc4b026791621b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 16:14:15 +0200 Subject: [PATCH 164/166] fix(mobile): serialize save-chain tail synchronously and release settled keys The tail promise was registered after an await, so back-to-back saves for the same key could read a stale predecessor and race instead of chaining in order; the in-flight map also never released keys once their chain settled. Register the tail synchronously and delete it once it settles, guarded so a newer tail is never clobbered. --- apps/mobile/src/lib/hooks/save-chain.test.ts | 94 +++++++++++++++++++- apps/mobile/src/lib/hooks/save-chain.ts | 29 ++++-- 2 files changed, 116 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/lib/hooks/save-chain.test.ts b/apps/mobile/src/lib/hooks/save-chain.test.ts index 293ebfc737..7e37143c47 100644 --- a/apps/mobile/src/lib/hooks/save-chain.test.ts +++ b/apps/mobile/src/lib/hooks/save-chain.test.ts @@ -1,6 +1,17 @@ import { describe, expect, it } from 'vitest'; -import { chainSave } from '@/lib/hooks/save-chain'; +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 () => { @@ -87,4 +98,85 @@ describe('chainSave', () => { 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 index f18231b481..01ff440b0b 100644 --- a/apps/mobile/src/lib/hooks/save-chain.ts +++ b/apps/mobile/src/lib/hooks/save-chain.ts @@ -18,14 +18,31 @@ async function awaitSettled(promise: Promise): Promise { * (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. + * 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); - if (previous) { - await awaitSettled(previous); - } - const next = run(); - inFlightSaves.set(key, awaitSettled(next)); + 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; +} From 0026ca3c2345d2eafa676c23fd3a36a813162226 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 12 Jul 2026 22:15:35 +0200 Subject: [PATCH 165/166] =?UTF-8?q?fix(mobile):=20e2e=20review=20follow-up?= =?UTF-8?q?s=20=E2=80=94=20honest=20error=20states,=20iOS=20IAP=20complian?= =?UTF-8?q?ce,=20section=20spacing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dashboard: don't show stale gateway state/uptime as live when the instance is stopped or gatewayStatus errors - kiloclaw list: preserve the cached list on a refresh error instead of blanking to a full-screen error (toast no longer contradicts the screen) - code-reviewer: platform list distinguishes a status error from "not connected"; review list classifies NOT_FOUND/FORBIDDEN/UNAUTHORIZED as permanent (no futile retry), matching the detail screen - agent session detail: NOT_FOUND renders a permanent "not available" state with no retry - query-client: skip retries on permanent errors (400/401/403/404/409) and cap retry delay so honest error states surface promptly instead of ~10s of skeleton - QueryError: default to a neutral "something went wrong" state rather than falsely implying offline - credits: gate the external "Add credits" CTA off iOS (App Store review); show the personal-IAP disclosure only in personal context, never under an org - refresh-failure copy: retry-forward wording, drop "showing the last saved …" - fix contentContainerClassName being dropped when contentContainerStyle is also set (NativeWind v5): route bottom clearance through a trailing spacer (TabScreenScrollView / new DetailScreenScrollView / FlatList footer) so horizontal padding and section gaps render across kiloclaw settings, billing, changelog, kilo-pass, profile, invoices, credit-activity, and findings --- .../app/(app)/(tabs)/(1_kiloclaw)/index.tsx | 4 +-- .../src/app/(app)/agent-chat/[session-id].tsx | 12 ++++++--- .../(app)/kiloclaw/[instance-id]/billing.tsx | 10 +++----- .../kiloclaw/[instance-id]/changelog.tsx | 10 +++----- .../kiloclaw/[instance-id]/dashboard.tsx | 25 +++++++------------ .../[instance-id]/settings/channels.tsx | 10 +++----- .../[instance-id]/settings/device-pairing.tsx | 10 +++----- .../[instance-id]/settings/exec-policy.tsx | 10 +++----- .../[instance-id]/settings/google.tsx | 10 +++----- .../[instance-id]/settings/secrets.tsx | 10 +++----- .../[instance-id]/settings/version-pin.tsx | 8 ++++-- .../mobile/src/components/add-credits-row.tsx | 8 +++++- .../agents/session-list-content.tsx | 2 +- .../code-reviewer/platform-list-screen.tsx | 12 ++++++++- .../code-reviewer/review-detail-screen.tsx | 2 +- .../code-reviewer/review-list-screen.tsx | 19 +++++++++++--- apps/mobile/src/components/detail-screen.tsx | 16 ++++++++++++ .../kilo-chat/conversation-list-screen.tsx | 2 +- .../kilo-pass-subscription-screen.tsx | 10 +++----- .../organization/credit-activity-screen.tsx | 2 +- .../organization/invoices-screen.tsx | 2 +- .../src/components/profile-credits-card.tsx | 13 +++++++--- apps/mobile/src/components/profile-screen.tsx | 12 +++------ apps/mobile/src/components/query-error.tsx | 5 +++- .../security-agent/finding-list-screen.tsx | 16 ++++++------ apps/mobile/src/components/tab-screen.tsx | 12 ++++++--- apps/mobile/src/lib/query-client.ts | 22 ++++++++++++++++ 27 files changed, 168 insertions(+), 106 deletions(-) create mode 100644 apps/mobile/src/components/detail-screen.tsx 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 4b5eb9371a..2305604567 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx @@ -46,14 +46,14 @@ export default function KiloClawTab() { const [manualRefreshing, handleRefresh] = useManualRefresh( refetchInstances, - 'Could not refresh. Showing the last saved list.' + "Couldn't refresh. Pull down to try again." ); // 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 || (entryDecision.kind !== 'list' && onboardingQuery.isError); + entryDecision.kind !== 'list' && (instancesQuery.isError || onboardingQuery.isError); if (hasQueryError) { return ( diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx index 42dcb7e057..c6fc84d4be 100644 --- a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx +++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx @@ -46,17 +46,21 @@ export default function SessionDetailScreen() { } if (routeOrganizationId === undefined && sessionQuery.isError) { + // A NOT_FOUND (e.g. the stored session was deleted) can't be recovered by + // retrying — show a permanent "not available" state with no Retry. Other + // errors stay transient and retriable. + const notFound = sessionQuery.error.data?.code === 'NOT_FOUND'; return ( void sessionQuery.refetch()} + title={notFound ? undefined : 'Could not load session'} + message={notFound ? undefined : 'Failed to load session details'} + onRetry={notFound ? undefined : () => void sessionQuery.refetch()} isRetrying={sessionQuery.isFetching} /> - + ); } 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 c2b81794dc..1cd9fa0fd7 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/changelog.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/changelog.tsx @@ -1,8 +1,9 @@ import { Newspaper } from 'lucide-react-native'; -import { Alert, ScrollView, View } from 'react-native'; +import { Alert, View } from 'react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { type Href, useLocalSearchParams, useRouter } from 'expo-router'; +import { DetailScreenScrollView } from '@/components/detail-screen'; import { EmptyState } from '@/components/empty-state'; import { ChangelogList } from '@/components/kiloclaw/changelog-list'; import { InstanceContextBoundary } from '@/components/kiloclaw/instance-context-boundary'; @@ -12,7 +13,6 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; 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 }>(); @@ -22,7 +22,6 @@ export default function ChangelogScreen() { const mutations = useKiloClawMutations(organizationId); const router = useRouter(); const entries = changelogQuery.data; - const paddingBottom = useDetailScreenBottomPadding(); function handleRedeploy() { Alert.alert('Redeploy instance', 'Are you sure you want to redeploy this instance?', [ @@ -81,9 +80,8 @@ export default function ChangelogScreen() { } return ( - @@ -99,7 +97,7 @@ export default function ChangelogScreen() { /> - + ); } 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 96e936c56d..dcd04699ed 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx @@ -1,17 +1,10 @@ import { type Href, useLocalSearchParams, useRouter } from 'expo-router'; import { CreditCard, Newspaper, Pencil } from 'lucide-react-native'; import { useCallback, useState } from 'react'; -import { - Alert, - Linking, - Platform, - Pressable, - RefreshControl, - ScrollView, - View, -} from 'react-native'; +import { Alert, Linking, Platform, Pressable, RefreshControl, View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; +import { DetailScreenScrollView } from '@/components/detail-screen'; import { BillingBanner } from '@/components/kiloclaw/billing-banner'; import { DangerZone, @@ -41,12 +34,10 @@ import { 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 paddingBottom = useDetailScreenBottomPadding(); const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>(); const instanceContext = useInstanceContext(instanceId); const organizationId = instanceOrgId(instanceContext); @@ -62,7 +53,10 @@ export default function DashboardScreen() { const isRunning = status?.status === 'running'; const gatewayQuery = useKiloClawGatewayStatus(organizationId, isRunning); - const gateway = gatewayQuery.data; + // Gateway data stays cached when the query is disabled (instance not running) + // or errors on refetch, so scope it to a live successful read — otherwise a + // stopped/errored instance shows stale "running" state and uptime as if live. + const gateway = isRunning && !gatewayQuery.isError ? gatewayQuery.data : undefined; const configQuery = useKiloClawConfig(organizationId); const activeModel = formatModelName(stripModelPrefix(configQuery.data?.kilocodeDefaultModel)); @@ -98,7 +92,7 @@ export default function DashboardScreen() { ]); const [manualRefreshing, handleRefresh] = useManualRefresh( refetchAll, - 'Could not refresh. Showing the last known status.' + "Couldn't refresh. Pull down to try again." ); if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { @@ -193,10 +187,9 @@ export default function DashboardScreen() { } /> - - + {renameVisible && ( (); @@ -19,7 +19,6 @@ export default function ChannelsScreen() { const organizationId = instanceOrgId(instanceContext); const catalogQuery = useKiloClawChannelCatalog(organizationId); const mutations = useKiloClawMutations(organizationId); - const paddingBottom = useDetailScreenBottomPadding(); const isLoading = catalogQuery.isPending; @@ -65,9 +64,8 @@ export default function ChannelsScreen() { return ( - ))} - + ); } 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 58eb6dc592..c53fbc6d26 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 @@ -1,7 +1,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { MessageSquare, Monitor, RefreshCw } from 'lucide-react-native'; import { useCallback } from 'react'; -import { Alert, Pressable, ScrollView, View } from 'react-native'; +import { Alert, Pressable, View } from 'react-native'; import Animated, { Easing, FadeIn, @@ -13,6 +13,7 @@ import Animated, { } from 'react-native-reanimated'; import { useLocalSearchParams } from 'expo-router'; +import { DetailScreenScrollView } from '@/components/detail-screen'; import { EmptyState } from '@/components/empty-state'; import { CATALOG_ICONS } from '@/components/icons'; import { InstanceContextBoundary } from '@/components/kiloclaw/instance-context-boundary'; @@ -27,7 +28,6 @@ import { 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 = { @@ -46,7 +46,6 @@ export default function DevicePairingScreen() { const pairingQuery = useKiloClawPairing(organizationId); const devicePairingQuery = useKiloClawDevicePairing(organizationId); const mutations = useKiloClawMutations(organizationId); - const paddingBottom = useDetailScreenBottomPadding(); const isLoading = pairingQuery.isPending || devicePairingQuery.isPending; @@ -171,9 +170,8 @@ export default function DevicePairingScreen() { return ( - {channelRequests.length > 0 && ( @@ -268,7 +266,7 @@ 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 8fb25d60a6..706e5572c6 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,8 +1,9 @@ import { type LucideIcon, ShieldCheck, Zap } from 'lucide-react-native'; -import { ActivityIndicator, Pressable, ScrollView, View } from 'react-native'; +import { ActivityIndicator, Pressable, View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; import { useLocalSearchParams } from 'expo-router'; +import { DetailScreenScrollView } from '@/components/detail-screen'; import { InstanceContextBoundary } from '@/components/kiloclaw/instance-context-boundary'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; @@ -12,7 +13,6 @@ import { instanceOrgId, useInstanceContext } from '@/lib/hooks/use-instance-cont 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 = { @@ -59,7 +59,6 @@ export default function ExecPolicyScreen() { 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); @@ -122,9 +121,8 @@ export default function ExecPolicyScreen() { return ( - @@ -174,7 +172,7 @@ export default function ExecPolicyScreen() { ); })} - + ); } 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 9347d44a5d..449d12af15 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,10 +1,11 @@ import * as Clipboard from 'expo-clipboard'; import { RefreshCw, Unplug } from 'lucide-react-native'; import { useState } from 'react'; -import { Alert, ScrollView, View } from 'react-native'; +import { Alert, View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; import { useLocalSearchParams } from 'expo-router'; +import { DetailScreenScrollView } from '@/components/detail-screen'; import { GmailIcon, GoogleIcon } from '@/components/icons'; import { InstanceContextBoundary } from '@/components/kiloclaw/instance-context-boundary'; import { QueryError } from '@/components/query-error'; @@ -19,7 +20,6 @@ import { 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'; @@ -29,7 +29,6 @@ export default function GoogleScreen() { const organizationId = instanceOrgId(instanceContext); const statusQuery = useKiloClawStatus(organizationId); const mutations = useKiloClawMutations(organizationId); - const paddingBottom = useDetailScreenBottomPadding(); const colors = useThemeColors(); const [copied, setCopied] = useState(false); @@ -130,9 +129,8 @@ export default function GoogleScreen() { return ( - @@ -251,7 +249,7 @@ export default function GoogleScreen() { )} - + ); } 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 45586e204d..0f4897f072 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 @@ -1,8 +1,9 @@ import { KeyRound } from 'lucide-react-native'; -import { ScrollView, View } from 'react-native'; +import { View } from 'react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { useLocalSearchParams } from 'expo-router'; +import { DetailScreenScrollView } from '@/components/detail-screen'; import { EmptyState } from '@/components/empty-state'; import { InstanceContextBoundary } from '@/components/kiloclaw/instance-context-boundary'; import { SettingsCard } from '@/components/kiloclaw/settings-card'; @@ -11,7 +12,6 @@ import { ScreenHeader } from '@/components/screen-header'; import { Skeleton } from '@/components/ui/skeleton'; 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 }>(); @@ -19,7 +19,6 @@ export default function SecretsScreen() { const organizationId = instanceOrgId(instanceContext); const mutations = useKiloClawMutations(organizationId); const catalogQuery = useKiloClawSecretCatalog(organizationId); - const paddingBottom = useDetailScreenBottomPadding(); const isLoading = catalogQuery.isPending; if (instanceContext.status === 'error' || instanceContext.status === 'not_found') { @@ -65,9 +64,8 @@ export default function SecretsScreen() { return ( - ))} - + ); } 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 0333ddaa19..ec21b751eb 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 @@ -243,7 +243,6 @@ export default function VersionPinScreen() { keyExtractor={item => item.image_tag} renderItem={renderVersionItem} contentContainerClassName="px-4 pt-4 gap-4" - contentContainerStyle={{ paddingBottom }} automaticallyAdjustKeyboardInsets keyboardDismissMode="interactive" keyboardShouldPersistTaps="handled" @@ -280,7 +279,12 @@ export default function VersionPinScreen() { /> ) } - ListFooterComponent={renderFooter()} + ListFooterComponent={ + <> + {renderFooter()} + + + } className="rounded-lg bg-secondary" /> diff --git a/apps/mobile/src/components/add-credits-row.tsx b/apps/mobile/src/components/add-credits-row.tsx index 795db9c795..9ceea5e857 100644 --- a/apps/mobile/src/components/add-credits-row.tsx +++ b/apps/mobile/src/components/add-credits-row.tsx @@ -1,4 +1,4 @@ -import { View } from 'react-native'; +import { Platform, View } from 'react-native'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; @@ -12,6 +12,12 @@ type AddCreditsRowProps = Readonly<{ /** Zero-balance CTA row: muted copy + an "Add credits" button to the web billing page. */ export function AddCreditsRow({ url, className }: AddCreditsRowProps) { + // App Store review: iOS must not show an in-app CTA that opens an external + // purchase/billing page. Credits are managed on the web there, so this row is + // Android-only — gate it here so no call site can surface it on iOS. + if (Platform.OS === 'ios') { + return null; + } return ( diff --git a/apps/mobile/src/components/agents/session-list-content.tsx b/apps/mobile/src/components/agents/session-list-content.tsx index 2f07290ec8..8b4fce4d5f 100644 --- a/apps/mobile/src/components/agents/session-list-content.tsx +++ b/apps/mobile/src/components/agents/session-list-content.tsx @@ -114,7 +114,7 @@ export function AgentSessionListContent({ ) : null} {showInlineError ? ( - Could not refresh — showing the last saved list. + Couldn't refresh. Pull down to try again. ) : null} 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 154485c022..31ef2fb5bf 100644 --- a/apps/mobile/src/components/code-reviewer/platform-list-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/platform-list-screen.tsx @@ -24,13 +24,23 @@ const PLATFORM_ICONS: Record = { const ALL_PLATFORMS = ['github', 'gitlab', 'bitbucket'] as const; -function connectionSubtitle(status: { isLoading: boolean; data?: { connected: boolean } }) { +function connectionSubtitle(status: { + isLoading: boolean; + isError: 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 'Checking…'; } + // A failed status query must not read as "Not connected" — that's a false + // disconnected signal. Say the state is unavailable; tapping in shows the + // full error + retry. + if (status.isError) { + return 'Status unavailable'; + } return status.data?.connected ? 'Connected' : 'Not connected'; } 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 e3bd9ed854..85d0b02525 100644 --- a/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx @@ -116,7 +116,7 @@ export function ReviewDetailScreen({ instead of replacing it with a full error screen. */} {isError && ( - Couldn't refresh — showing the last known status. + Couldn't get the latest. )} 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 608b3ba325..624ab06588 100644 --- a/apps/mobile/src/components/code-reviewer/review-list-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/review-list-screen.tsx @@ -47,12 +47,23 @@ function reviewTime(review: Review): Date { export function ReviewListScreen({ scope }: Readonly<{ scope: string }>) { const router = useRouter(); - const { data, isLoading, isError, isFetching, refetch } = useReviewList(scope); + const { data, isLoading, isError, isFetching, error, refetch } = useReviewList(scope); const githubStatus = useGitHubStatus(scope); const gitlabStatus = useGitLabStatus(scope); const hasConnectedProvider = githubStatus.data?.connected === true || gitlabStatus.data?.connected === true; + // A thrown NOT_FOUND/FORBIDDEN/UNAUTHORIZED can't be fixed by retrying — mirror + // the review-detail screen and show a permanent state with no retry. Any other + // thrown error (or the resolved success:false shape below) stays transient. + const errorCode = isError ? error.data?.code : undefined; + const isPermanentError = + errorCode === 'NOT_FOUND' || errorCode === 'FORBIDDEN' || errorCode === 'UNAUTHORIZED'; + let errorVariant: 'server' | 'not-found' | 'permission' = 'server'; + if (isPermanentError) { + errorVariant = errorCode === 'NOT_FOUND' ? 'not-found' : 'permission'; + } + return ( @@ -71,10 +82,10 @@ export function ReviewListScreen({ scope }: Readonly<{ scope: string }>) { not hide it behind a retry banner. */} {!isLoading && isError && !data && ( void refetch()} + title={isPermanentError ? undefined : 'Could not load reviews'} + onRetry={isPermanentError ? undefined : () => void refetch()} isRetrying={isFetching} /> )} diff --git a/apps/mobile/src/components/detail-screen.tsx b/apps/mobile/src/components/detail-screen.tsx new file mode 100644 index 0000000000..0086af11bf --- /dev/null +++ b/apps/mobile/src/components/detail-screen.tsx @@ -0,0 +1,16 @@ +import { ScrollView, type ScrollViewProps, View } from 'react-native'; + +import { useDetailScreenBottomPadding } from '@/lib/screen-insets'; + +// Detail-screen counterpart to TabScreenScrollView. Provides bottom clearance via +// a trailing spacer instead of contentContainerStyle — setting that style prop +// makes NativeWind drop the caller's contentContainerClassName (padding/gap). +export function DetailScreenScrollView({ children, ...props }: ScrollViewProps) { + const paddingBottom = useDetailScreenBottomPadding(); + return ( + + {children} + + + ); +} 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 1c0bb725cb..d62388cc4f 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx @@ -168,7 +168,7 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { const [manualRefreshing, handleRefresh] = useManualRefresh( refetchConversations, - 'Could not refresh. Showing the last saved list.' + "Couldn't refresh. Pull down to try again." ); const contentState = getConversationListContentState({ 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 b9f5eb3725..c5b5b8e73a 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,9 +1,10 @@ import * as Haptics from 'expo-haptics'; import { useRouter } from 'expo-router'; import { useEffect, useState } from 'react'; -import { ActivityIndicator, Pressable, ScrollView, View } from 'react-native'; +import { ActivityIndicator, Pressable, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { DetailScreenScrollView } from '@/components/detail-screen'; import { ScreenHeader } from '@/components/screen-header'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; @@ -23,7 +24,6 @@ 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'; @@ -40,7 +40,6 @@ 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(); @@ -71,10 +70,9 @@ export function KiloPassSubscriptionScreen() { - @@ -189,7 +187,7 @@ export function KiloPassSubscriptionScreen() { . - + {purchase.isPending && ( diff --git a/apps/mobile/src/components/organization/credit-activity-screen.tsx b/apps/mobile/src/components/organization/credit-activity-screen.tsx index f383cc7dc9..3764895349 100644 --- a/apps/mobile/src/components/organization/credit-activity-screen.tsx +++ b/apps/mobile/src/components/organization/credit-activity-screen.tsx @@ -102,7 +102,6 @@ export function OrganizationCreditActivityScreen() { keyExtractor={item => item.id} renderItem={({ item }) => } contentContainerClassName="grow gap-3 px-6 pt-4" - contentContainerStyle={{ paddingBottom }} ListEmptyComponent={ } + ListFooterComponent={} /> ); diff --git a/apps/mobile/src/components/organization/invoices-screen.tsx b/apps/mobile/src/components/organization/invoices-screen.tsx index 1cdafcb218..2ae54f8ae4 100644 --- a/apps/mobile/src/components/organization/invoices-screen.tsx +++ b/apps/mobile/src/components/organization/invoices-screen.tsx @@ -105,7 +105,6 @@ export function OrganizationInvoicesScreen() { keyExtractor={item => item.id} renderItem={({ item }) => } contentContainerClassName="grow gap-3 px-6 pt-4" - contentContainerStyle={{ paddingBottom }} ListEmptyComponent={ } + ListFooterComponent={} /> ); diff --git a/apps/mobile/src/components/profile-credits-card.tsx b/apps/mobile/src/components/profile-credits-card.tsx index 94aaeb7f3a..362e32d4f2 100644 --- a/apps/mobile/src/components/profile-credits-card.tsx +++ b/apps/mobile/src/components/profile-credits-card.tsx @@ -164,19 +164,24 @@ export function CreditsCard({ enabled, orgs }: Readonly) { {balanceFetching && } )} + {!balanceLoading && !balanceError && balanceDollars === 0 && canShowZeroBalanceCta && ( + + )} + {/* Personal-context IAP disclosure only — never show this personal "managed + outside the iOS app" copy under an org context (org billing isn't personal + IAP, and a non-money-role member just lacks access). */} {!balanceLoading && !balanceError && balanceDollars === 0 && - (canShowZeroBalanceCta ? ( - - ) : ( + !canShowZeroBalanceCta && + selectedOrgId == null && ( 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 077628bc4d..490cae54d2 100644 --- a/apps/mobile/src/components/profile-screen.tsx +++ b/apps/mobile/src/components/profile-screen.tsx @@ -11,9 +11,8 @@ import { ShieldCheck, Trash2, } from 'lucide-react-native'; -import { Alert, Platform, ScrollView, View } from 'react-native'; +import { Alert, Platform, 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'; import { RestorePurchasesButton } from '@/components/kilo-pass/restore-purchases-button'; @@ -22,6 +21,7 @@ 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 { TabScreenScrollView } from '@/components/tab-screen'; import { ConfigureRow } from '@/components/ui/configure-row'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; @@ -32,7 +32,6 @@ import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { useOrganization } from '@/lib/organization-context'; import { getCodeReviewerProfilePath, getProfileAgentScope } from '@/lib/profile-agent-navigation'; import { getSecurityAgentPath } from '@/lib/security-agent'; -import { getTabBarOverlayHeight } from '@/lib/tab-bar-layout'; import { useTRPC } from '@/lib/trpc'; function providerIcon(_provider: string) { @@ -74,8 +73,6 @@ export function ProfileScreen() { const { userId } = useCurrentUserId({ enabled: isAuthenticated }); - const { bottom } = useSafeAreaInsets(); - const deleteAccount = useMutation( trpc.user.requestAccountDeletion.mutationOptions({ onSuccess: () => { @@ -124,10 +121,9 @@ export function ProfileScreen() { return ( - {/* Credits */} @@ -289,7 +285,7 @@ export function ProfileScreen() { v{Application.nativeApplicationVersion} ({Application.nativeBuildVersion}) - + ); } diff --git a/apps/mobile/src/components/query-error.tsx b/apps/mobile/src/components/query-error.tsx index e42a807710..87eec290d3 100644 --- a/apps/mobile/src/components/query-error.tsx +++ b/apps/mobile/src/components/query-error.tsx @@ -55,7 +55,10 @@ type QueryErrorProps = { }; export function QueryError({ - variant = 'offline', + // Default to the generic "unknown" state — asserting 'offline' when we don't + // actually know the cause is a false signal (a 500 is not a connectivity + // problem). Callers pass an explicit variant when the cause is known. + variant = 'neutral', title, message, onRetry, 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 f2973aa7cf..139d98ec0b 100644 --- a/apps/mobile/src/components/security-agent/finding-list-screen.tsx +++ b/apps/mobile/src/components/security-agent/finding-list-screen.tsx @@ -82,7 +82,7 @@ export function FindingListScreen({ scope, routeParams }: Readonly )} contentContainerClassName="grow gap-3 px-6 pt-4" - contentContainerStyle={{ paddingBottom }} refreshControl={} onEndReached={() => { if (findings.hasNextPage && !findings.isFetchingNextPage) { @@ -160,11 +159,14 @@ export function FindingListScreen({ scope, routeParams }: Readonly void findings.fetchNextPage()} - /> + <> + void findings.fetchNextPage()} + /> + + } ListEmptyComponent={ + + {children} + + ); } diff --git a/apps/mobile/src/lib/query-client.ts b/apps/mobile/src/lib/query-client.ts index 277750a5a3..faeb2061b1 100644 --- a/apps/mobile/src/lib/query-client.ts +++ b/apps/mobile/src/lib/query-client.ts @@ -2,8 +2,30 @@ import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query'; import { handleTrpcQueryError } from '@/lib/auth/trpc-unauthorized'; +// tRPC error codes that retrying can never fix — surface these immediately +// instead of sitting on a skeleton through the default retry backoff. +const PERMANENT_CODES = new Set([ + 'BAD_REQUEST', + 'UNAUTHORIZED', + 'FORBIDDEN', + 'NOT_FOUND', + 'CONFLICT', +]); + export function createKiloAppQueryClient(): QueryClient { return new QueryClient({ + defaultOptions: { + queries: { + retry: (failureCount, error) => { + const code = (error as { data?: { code?: string } } | null)?.data?.code; + if (code !== undefined && PERMANENT_CODES.has(code)) { + return false; + } + return failureCount < 2; + }, + retryDelay: attempt => Math.min(1000 * 2 ** attempt, 3000), + }, + }, queryCache: new QueryCache({ onError: error => { handleTrpcQueryError(error); From fa44310b0063c464b154e924f63e4c01b77739e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 13 Jul 2026 02:54:11 +0200 Subject: [PATCH 166/166] =?UTF-8?q?fix(mobile+web):=20address=20e2e=20UX?= =?UTF-8?q?=20findings=20=E2=80=94=20auto-reviews=20opt-in,=20error=20clas?= =?UTF-8?q?sification,=20a11y,=20honest=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness / honest data: - code-reviewer: editing a sub-setting no longer silently enables Automatic reviews (agent-config upsert defaulted is_enabled to true on insert; now false — enabling stays an explicit toggle) - security dashboard: per-repo SLA compliance shows "Not measured" instead of a misleading 100% when there are no SLA-tracked findings (SQL NULL; web + mobile) - mobile signs out only on a genuine context auth failure (data.authRequired), not on any 401 — a procedure-level UNAUTHORIZED (e.g. org-access denial) no longer logs the whole app out - code-reviewer platform-overview/manual-review classify FORBIDDEN/UNAUTHORIZED/ NOT_FOUND as permanent (permission/not-found, no retry) instead of retriable - kiloclaw Google setup command no longer emits a literal ${GMAIL_PUSH_WORKER_URL} UX / visual: - Profile "Linked accounts" section hides when there are no providers - Home shows the agents onboarding promo for CLI-only accounts instead of an empty section + orphaned "New coding task" button - Dynamic Type: Button uses min-height; credits balance, Kilo Pass card, security "Analyze", new-session pickers, and the kiloclaw dashboard header no longer clip/overflow at accessibility sizes - security-agent telemetry (trackUiInteraction) no longer shows/stacks error toasts on navigation - kiloclaw dashboard shows the instance's friendly name, not the raw sandbox id - org hub "Seats" drops the "/ 0" when no seat limit is set - copy/style: review "Platform" casing (GitHub), Channels subtitle, Kilo Pass "$N in credits", Secrets fallback icon, bot-name spellCheck off, manual-review keyboard-dismiss on drag Tests: agent-config insert default, dashboard-stats SLA null, provider error classification, and auth-context sign-out gating. --- .../kiloclaw/[instance-id]/dashboard.tsx | 9 ++- .../src/components/agents/chat-toolbar.tsx | 9 ++- .../code-reviewer/manual-review-screen.tsx | 28 +++++-- .../platform-overview-screen.tsx | 13 ++- .../code-reviewer/review-detail-screen.tsx | 3 +- .../home/agent-sessions-section.tsx | 15 ++++ .../src/components/home/home-screen.tsx | 10 ++- .../kilo-pass/kilo-pass-subscription-card.tsx | 6 +- .../kilo-pass-subscription-screen.tsx | 2 +- .../components/kiloclaw/dashboard-parts.tsx | 5 +- .../kiloclaw/onboarding/identity-step.tsx | 1 + .../src/components/kiloclaw/settings-card.tsx | 6 +- .../src/components/kiloclaw/settings-list.tsx | 2 +- .../components/organization/hub-screen.tsx | 12 ++- .../src/components/platform-error-screen.tsx | 3 +- .../src/components/profile-credits-card.tsx | 6 +- apps/mobile/src/components/profile-screen.tsx | 79 ++++++++++--------- .../security-agent/dashboard-sections.tsx | 14 +++- apps/mobile/src/components/ui/button.tsx | 11 +-- apps/mobile/src/lib/auth/trpc-unauthorized.ts | 8 +- apps/mobile/src/lib/code-reviewer-config.ts | 11 +++ .../src/lib/code-reviewer-status.test.ts | 34 +++++++- apps/mobile/src/lib/code-reviewer-status.ts | 43 +++++++++- .../lib/hooks/use-security-agent-mutations.ts | 7 +- apps/mobile/src/lib/query-client.test.ts | 6 +- apps/mobile/src/lib/trpc-unauthorized.test.ts | 22 ++++-- .../security-agent/SecurityDashboard.tsx | 18 +++-- .../dashboard/RepositoryHealthTable.tsx | 11 ++- .../src/lib/agent-config/db/agent-configs.ts | 6 +- .../security-agent/db/dashboard-stats.test.ts | 3 +- .../lib/security-agent/db/dashboard-stats.ts | 10 ++- apps/web/src/lib/trpc/init.ts | 6 +- apps/web/src/lib/trpc/transport.ts | 17 ++++ .../src/routers/code-reviews-router.test.ts | 25 ++++++ apps/web/src/routers/kiloclaw-router.ts | 5 +- 35 files changed, 355 insertions(+), 111 deletions(-) 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 dcd04699ed..e7f997936f 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx @@ -141,7 +141,12 @@ export default function DashboardScreen() { ); } - const instanceName = status?.name ?? status?.sandboxId ?? 'Instance'; + // Prefer the instance's friendly name from the list/context (the gateway + // status query doesn't carry it, so relying on status.name showed the raw + // sandbox id for named instances). + const contextName = + instanceContext.status === 'ready' ? instanceContext.instance.name : undefined; + const instanceName = contextName ?? status?.name ?? status?.sandboxId ?? 'Instance'; const handleDestroy = () => { Alert.alert( @@ -290,7 +295,7 @@ export default function DashboardScreen() { { await mutations.renameInstance.mutateAsync({ name }); }} diff --git a/apps/mobile/src/components/agents/chat-toolbar.tsx b/apps/mobile/src/components/agents/chat-toolbar.tsx index 977e4e4bb3..92f41695a9 100644 --- a/apps/mobile/src/components/agents/chat-toolbar.tsx +++ b/apps/mobile/src/components/agents/chat-toolbar.tsx @@ -54,11 +54,14 @@ export function ChatToolbar({ return ( {order === 'model-first' ? modelSelector : modeSelector} {order === 'model-first' ? modeSelector : modelSelector} - {showReasoningSettings ? ( <> ) { }; if (!statusesLoading && statusesError && !isConnected('github') && !isConnected('gitlab')) { + // A permission/not-found error can't be fixed by retrying — show the + // permanent variant with no retry instead of a misleading "try again". + const statusErrorCode = + (githubStatus.error as { data?: { code?: string } } | null)?.data?.code ?? + (gitlabStatus.error as { data?: { code?: string } } | null)?.data?.code; + const { permanent, variant } = classifyProviderErrorCode(statusErrorCode); return ( { - void githubStatus.refetch(); - void gitlabStatus.refetch(); - }} + variant={variant} + title={permanent ? undefined : "Couldn't check provider status"} + message={ + permanent ? undefined : "We couldn't load your connected providers. Please try again." + } + onRetry={ + permanent + ? undefined + : () => { + void githubStatus.refetch(); + void gitlabStatus.refetch(); + } + } isRetrying={githubStatus.isRefetching || gitlabStatus.isRefetching} /> @@ -156,6 +169,7 @@ export function ManualReviewScreen({ scope }: Readonly<{ scope: string }>) { contentContainerClassName="gap-6 pt-4" automaticallyAdjustKeyboardInsets keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" > 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 78a6a46348..a016d7862e 100644 --- a/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx @@ -84,6 +84,7 @@ export function PlatformOverviewScreen({ connected: status.data?.connected, hasData: status.data !== undefined, refetch: () => void status.refetch(), + errorCode: (status.error as { data?: { code?: string } } | null)?.data?.code, }); if (providerState.status === 'error') { @@ -91,9 +92,15 @@ export function PlatformOverviewScreen({ { - providerState.refetch(); - }} + variant={providerState.variant} + // A permission/not-found error can't be fixed by retrying — hide retry. + onRetry={ + providerState.permanent + ? undefined + : () => { + providerState.refetch(); + } + } isRetrying={providerState.isRetrying} /> ); 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 85d0b02525..31d802fe86 100644 --- a/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx @@ -14,6 +14,7 @@ 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 { reviewerPlatformLabel } from '@/lib/code-reviewer-config'; import { openExternalUrl } from '@/lib/external-link'; import { useCancelReview, useRetriggerReview, useReviewDetail } from '@/lib/hooks/use-code-reviews'; import { cn, parseTimestamp, timeAgo } from '@/lib/utils'; @@ -130,7 +131,7 @@ export function ReviewDetailScreen({ - + {review.model ? : null} {review.started_at ? ( diff --git a/apps/mobile/src/components/home/agent-sessions-section.tsx b/apps/mobile/src/components/home/agent-sessions-section.tsx index facdff5d18..c3e448fe6e 100644 --- a/apps/mobile/src/components/home/agent-sessions-section.tsx +++ b/apps/mobile/src/components/home/agent-sessions-section.tsx @@ -119,6 +119,21 @@ function buildRows(params: { return rows; } +// Whether the Home "Agent sessions" section has anything to render — mirrors +// buildRows' inclusion rule (any active session, or a cloud-agent stored +// session; stored CLI/other-platform sessions live on the Agents tab, not +// Home). The Home screen gates its section/promo/new-task button on this so a +// CLI-only account shows the first-use promo instead of an empty section. +export function hasDisplayableAgentSessions( + storedSessions: StoredSession[], + activeSessions: ActiveSession[] +): boolean { + return ( + activeSessions.length > 0 || + storedSessions.some(s => CLOUD_AGENT_PLATFORMS.has(s.created_on_platform)) + ); +} + type AgentSessionsSectionProps = { organizationId: string | null; }; diff --git a/apps/mobile/src/components/home/home-screen.tsx b/apps/mobile/src/components/home/home-screen.tsx index b9b59ce319..f41d2c019c 100644 --- a/apps/mobile/src/components/home/home-screen.tsx +++ b/apps/mobile/src/components/home/home-screen.tsx @@ -8,7 +8,10 @@ import { TabScreenScrollView } from '@/components/tab-screen'; import { badgeBucketForInstance } from '@kilocode/notifications'; -import { AgentSessionsSection } from '@/components/home/agent-sessions-section'; +import { + AgentSessionsSection, + hasDisplayableAgentSessions, +} from '@/components/home/agent-sessions-section'; import { AgentsPromoCard } from '@/components/home/agents-promo-card'; import { buildTimedGreeting } from '@/components/home/greeting'; import { KiloClawPromoCard } from '@/components/home/kiloclaw-promo-card'; @@ -98,7 +101,10 @@ export function HomeScreen() { const isLoading = instancesPending || sessionsLoading; - const hasAnySession = storedSessions.length > 0 || activeSessions.length > 0; + // Match what the Home Agent-sessions section actually renders (cloud-agent + // stored + any active), so a CLI-only account shows the first-use promo + // instead of an empty section + orphaned "New coding task" button. + const hasAnySession = hasDisplayableAgentSessions(storedSessions, activeSessions); const headerTitle = buildTimedGreeting(); const handleRefresh = useCallback(() => { diff --git a/apps/mobile/src/components/kilo-pass/kilo-pass-subscription-card.tsx b/apps/mobile/src/components/kilo-pass/kilo-pass-subscription-card.tsx index 87c41ea463..7d9b92f1b7 100644 --- a/apps/mobile/src/components/kilo-pass/kilo-pass-subscription-card.tsx +++ b/apps/mobile/src/components/kilo-pass/kilo-pass-subscription-card.tsx @@ -154,7 +154,9 @@ export function KiloPassSubscriptionCard() { {contentState.title} {contentState.description} - {contentState.actionLabel} + + {contentState.actionLabel} + ) : null} @@ -198,7 +200,7 @@ export function KiloPassSubscriptionCard() { {contentState.state.actionLabel ? ( - + {contentState.state.actionLabel} ) : null} 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 c5b5b8e73a..5a8bb4c238 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 @@ -30,7 +30,7 @@ import { RestorePurchasesButton } from './restore-purchases-button'; type SubscriptionScreenFeedback = { type: 'success' | 'info' | 'error'; text: string }; function formatTier(product: AppStoreKiloPassProduct): string { - return `$${product.webMonthlyPriceUsd} credits`; + return `$${product.webMonthlyPriceUsd} in credits`; } function formatStorePrice(product: AppStoreKiloPassProduct): string { diff --git a/apps/mobile/src/components/kiloclaw/dashboard-parts.tsx b/apps/mobile/src/components/kiloclaw/dashboard-parts.tsx index 49401507bd..fc868c13c1 100644 --- a/apps/mobile/src/components/kiloclaw/dashboard-parts.tsx +++ b/apps/mobile/src/components/kiloclaw/dashboard-parts.tsx @@ -46,10 +46,7 @@ export function DashboardHero({ name, status, uptime }: Readonly - + {name} diff --git a/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx b/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx index 3d94fc2d77..2b25467582 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx @@ -287,6 +287,7 @@ export function IdentityStep({ }} autoCapitalize="words" autoCorrect={false} + spellCheck={false} maxLength={80} returnKeyType="done" /> diff --git a/apps/mobile/src/components/kiloclaw/settings-card.tsx b/apps/mobile/src/components/kiloclaw/settings-card.tsx index 053688cf5a..304edac543 100644 --- a/apps/mobile/src/components/kiloclaw/settings-card.tsx +++ b/apps/mobile/src/components/kiloclaw/settings-card.tsx @@ -5,6 +5,7 @@ import { ExternalLink, Eye, EyeOff, + KeyRound, Trash2, } from 'lucide-react-native'; import { useCallback, useRef, useState } from 'react'; @@ -164,6 +165,7 @@ export function SettingsCard({ const [isSaving, setIsSaving] = useState(false); const [isRemoving, setIsRemoving] = useState(false); const fieldValuesRef = useRef>({}); + const colors = useThemeColors(); const ItemIcon = CATALOG_ICONS[item.id]; const updateCanSave = useCallback(() => { @@ -242,7 +244,9 @@ export function SettingsCard({ {/* Header row */} - {ItemIcon && } + {/* Fall back to a neutral key icon for catalog items without a brand + icon (e.g. Linear, Composio) so every row has one. */} + {ItemIcon ? : } {item.label} {item.helpText && diff --git a/apps/mobile/src/components/kiloclaw/settings-list.tsx b/apps/mobile/src/components/kiloclaw/settings-list.tsx index 48686a6be1..0ca4ab4e4f 100644 --- a/apps/mobile/src/components/kiloclaw/settings-list.tsx +++ b/apps/mobile/src/components/kiloclaw/settings-list.tsx @@ -36,7 +36,7 @@ const SETTINGS_ITEMS: SettingsItem[] = [ { icon: MessageSquare, label: 'Channels', - description: 'Telegram, Discord, Slack, GitHub', + description: 'Telegram, Discord, Slack', path: 'settings/channels', }, { diff --git a/apps/mobile/src/components/organization/hub-screen.tsx b/apps/mobile/src/components/organization/hub-screen.tsx index 6eb7b2c9f9..717f532990 100644 --- a/apps/mobile/src/components/organization/hub-screen.tsx +++ b/apps/mobile/src/components/organization/hub-screen.tsx @@ -77,7 +77,17 @@ export function OrganizationHubScreen() { className="border-b-[0.5px] border-hair-soft py-3" /> )} - + 0 + ? `${org.seatCount.used} / ${org.seatCount.total}` + : String(org.seatCount.used) + } + last + /> diff --git a/apps/mobile/src/components/platform-error-screen.tsx b/apps/mobile/src/components/platform-error-screen.tsx index 02aefebe46..14959dc30f 100644 --- a/apps/mobile/src/components/platform-error-screen.tsx +++ b/apps/mobile/src/components/platform-error-screen.tsx @@ -27,7 +27,8 @@ export function PlatformErrorScreen({ errorTitle?: string; message?: string; variant?: QueryErrorVariant; - onRetry: () => void; + /** Omit to render a non-retriable state (e.g. permission/not-found errors). */ + onRetry?: () => void; isRetrying?: boolean; }>) { const paddingBottom = useTabBarBottomPadding(); diff --git a/apps/mobile/src/components/profile-credits-card.tsx b/apps/mobile/src/components/profile-credits-card.tsx index 362e32d4f2..cf0bbf7570 100644 --- a/apps/mobile/src/components/profile-credits-card.tsx +++ b/apps/mobile/src/components/profile-credits-card.tsx @@ -132,17 +132,17 @@ export function CreditsCard({ enabled, orgs }: Readonly) { )} - {balanceLoading && } + {balanceLoading && } {balanceError && ( void refetchBalance()} > Failed to load balance. Tap to retry. )} {!balanceLoading && !balanceError && ( - + {formatDollars(balanceDollars)} {creditsLoading ? ( diff --git a/apps/mobile/src/components/profile-screen.tsx b/apps/mobile/src/components/profile-screen.tsx index 490cae54d2..de87cd3876 100644 --- a/apps/mobile/src/components/profile-screen.tsx +++ b/apps/mobile/src/components/profile-screen.tsx @@ -192,47 +192,50 @@ export function ProfileScreen() { )} - {/* Linked accounts */} - - - Linked accounts - + {/* Linked accounts — hide the whole section when there are no linked + providers (and we're not loading/erroring) so the header never dangles. */} + {(isLoading || providersError || (data?.providers.length ?? 0) > 0) && ( + + + Linked accounts + - {isLoading && ( - - - - )} + {isLoading && ( + + + + )} - {providersError && ( - void refetchProviders()} - isRetrying={providersFetching} - /> - )} + {providersError && ( + void refetchProviders()} + isRetrying={providersFetching} + /> + )} - {data?.providers.map(p => { - const Icon = providerIcon(p.provider); - return ( - - - - {p.provider} - - {p.email} - - - - ); - })} - + {data?.providers.map(p => { + const Icon = providerIcon(p.provider); + return ( + + + + {p.provider} + + {p.email} + + + + ); + })} + + )} {/* Notifications */} diff --git a/apps/mobile/src/components/security-agent/dashboard-sections.tsx b/apps/mobile/src/components/security-agent/dashboard-sections.tsx index 5b9925265d..5fc8fa2620 100644 --- a/apps/mobile/src/components/security-agent/dashboard-sections.tsx +++ b/apps/mobile/src/components/security-agent/dashboard-sections.tsx @@ -26,6 +26,18 @@ type SectionProps = Readonly<{ repoFullName: string | undefined; }>; +// A null slaCompliancePercent means the repo has no open SLA-tracked findings, +// so show "Not measured" rather than a misleading 100%. +function repoTrailingLabel( + repo: { slaCompliancePercent: number | null; needsAction: number }, + slaEnabled: boolean +): string { + if (!slaEnabled) { + return `${repo.needsAction} findings`; + } + return repo.slaCompliancePercent == null ? 'Not measured' : `${repo.slaCompliancePercent}%`; +} + function findingsHref( scope: string, params: Record @@ -272,7 +284,7 @@ function RepoHealthSection({ scope, data, slaEnabled }: SectionProps) { - {slaEnabled ? `${repo.slaCompliancePercent}%` : `${repo.needsAction} findings`} + {repoTrailingLabel(repo, slaEnabled)} diff --git a/apps/mobile/src/components/ui/button.tsx b/apps/mobile/src/components/ui/button.tsx index 7103fe92d1..f8435650c9 100644 --- a/apps/mobile/src/components/ui/button.tsx +++ b/apps/mobile/src/components/ui/button.tsx @@ -19,11 +19,12 @@ const buttonVariants = cva( 'accent-soft': 'bg-accent-soft active:opacity-80 shadow-sm shadow-black/5', }, size: { - // 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', + // min-h (not fixed h) so the button grows to fit text scaled by large + // Dynamic Type instead of clipping the label; the min still guarantees + // the 44pt (default/lg) / 36pt-plus-hitSlop (sm) touch target. + default: 'min-h-11 px-4 py-2', + sm: 'min-h-9 gap-1.5 rounded-md px-3 py-1.5', + lg: 'min-h-11 rounded-md px-6 py-2', icon: 'h-11 w-11', }, }, diff --git a/apps/mobile/src/lib/auth/trpc-unauthorized.ts b/apps/mobile/src/lib/auth/trpc-unauthorized.ts index 3d07a988d1..590e982be9 100644 --- a/apps/mobile/src/lib/auth/trpc-unauthorized.ts +++ b/apps/mobile/src/lib/auth/trpc-unauthorized.ts @@ -2,13 +2,17 @@ import { z } from 'zod'; type TrpcUnauthorizedHandler = () => Promise | void; +// Sign out only on the server's context-level auth failure (invalid/missing +// token), flagged via data.authRequired — NOT on every 401. A procedure-level +// UNAUTHORIZED (e.g. org-access denial) is also HTTP 401 but must be handled +// in-screen as a permission error, not by logging the whole app out. const DirectUnauthorizedErrorSchema = z.looseObject({ - data: z.looseObject({ httpStatus: z.literal(401) }), + data: z.looseObject({ authRequired: z.literal(true) }), }); const ShapedUnauthorizedErrorSchema = z.looseObject({ shape: z.looseObject({ - data: z.looseObject({ httpStatus: z.literal(401) }), + data: z.looseObject({ authRequired: z.literal(true) }), }), }); diff --git a/apps/mobile/src/lib/code-reviewer-config.ts b/apps/mobile/src/lib/code-reviewer-config.ts index 4f9e184d61..efc38108fc 100644 --- a/apps/mobile/src/lib/code-reviewer-config.ts +++ b/apps/mobile/src/lib/code-reviewer-config.ts @@ -52,6 +52,17 @@ export const PLATFORM_CAPABILITIES: Record< const REVIEWER_PLATFORMS = Object.keys(PLATFORM_CAPABILITIES) as ReviewerPlatform[]; +/** + * Display label for a code-review platform (e.g. 'github' → 'GitHub'), falling + * back to the raw value for anything unrecognized. Use this instead of a CSS + * `capitalize`, which renders 'github' → 'Github'. + */ +export function reviewerPlatformLabel(platform: string): string { + return REVIEWER_PLATFORMS.includes(platform as ReviewerPlatform) + ? PLATFORM_CAPABILITIES[platform as ReviewerPlatform].label + : platform; +} + /** * Strictly parses a route's platform segment against the supported * scope+platform combinations. Replaces the old `asReviewerPlatform` diff --git a/apps/mobile/src/lib/code-reviewer-status.test.ts b/apps/mobile/src/lib/code-reviewer-status.test.ts index 488c2c6324..2f93a15175 100644 --- a/apps/mobile/src/lib/code-reviewer-status.test.ts +++ b/apps/mobile/src/lib/code-reviewer-status.test.ts @@ -1,6 +1,38 @@ import { describe, expect, it, vi } from 'vitest'; -import { classifyPermission, classifyProviderState } from './code-reviewer-status'; +import { + classifyPermission, + classifyProviderErrorCode, + classifyProviderState, +} from './code-reviewer-status'; + +describe('classifyProviderErrorCode', () => { + it('treats FORBIDDEN and UNAUTHORIZED as a permanent permission error (no retry)', () => { + expect(classifyProviderErrorCode('FORBIDDEN')).toEqual({ + permanent: true, + variant: 'permission', + }); + expect(classifyProviderErrorCode('UNAUTHORIZED')).toEqual({ + permanent: true, + variant: 'permission', + }); + }); + + it('treats NOT_FOUND as a permanent not-found error', () => { + expect(classifyProviderErrorCode('NOT_FOUND')).toEqual({ + permanent: true, + variant: 'not-found', + }); + }); + + it('treats other/unknown codes as a transient, retriable server error', () => { + expect(classifyProviderErrorCode('INTERNAL_SERVER_ERROR')).toEqual({ + permanent: false, + variant: 'server', + }); + expect(classifyProviderErrorCode(undefined)).toEqual({ permanent: false, variant: 'server' }); + }); +}); describe('classifyProviderState', () => { it('is loading while the query is loading', () => { diff --git a/apps/mobile/src/lib/code-reviewer-status.ts b/apps/mobile/src/lib/code-reviewer-status.ts index 6890642a9e..988e0dd787 100644 --- a/apps/mobile/src/lib/code-reviewer-status.ts +++ b/apps/mobile/src/lib/code-reviewer-status.ts @@ -11,9 +11,39 @@ import { canManageOrganizationBilling } from '@kilocode/app-shared/organizations // 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 ProviderErrorVariant = 'server' | 'permission' | 'not-found'; + +/** + * Classify a TRPC error code into a provider error variant. A thrown + * FORBIDDEN/UNAUTHORIZED/NOT_FOUND can't be fixed by retrying, so it's + * `permanent` (rendered with the permission/not-found QueryError variant and + * no retry); anything else is a transient server error. + */ +export function classifyProviderErrorCode(errorCode: string | undefined): { + permanent: boolean; + variant: ProviderErrorVariant; +} { + const permanent = + errorCode === 'FORBIDDEN' || errorCode === 'UNAUTHORIZED' || errorCode === 'NOT_FOUND'; + let variant: ProviderErrorVariant = 'server'; + if (errorCode === 'NOT_FOUND') { + variant = 'not-found'; + } else if (permanent) { + variant = 'permission'; + } + return { permanent, variant }; +} + type ProviderState = | { status: 'loading' } - | { status: 'error'; refetch: () => void; isRetrying: boolean } + | { + status: 'error'; + refetch: () => void; + isRetrying: boolean; + /** FORBIDDEN/UNAUTHORIZED/NOT_FOUND — a retry can't fix it, so hide retry. */ + permanent: boolean; + variant: ProviderErrorVariant; + } | { status: 'connected' } | { status: 'disconnected' }; @@ -24,6 +54,8 @@ export function classifyProviderState(input: { connected: boolean | undefined; hasData: boolean; refetch: () => unknown; + /** TRPC error code (error.data?.code) when isError, for permanent-vs-transient classification. */ + errorCode?: string; }): ProviderState { if (input.isLoading) { return { status: 'loading' }; @@ -32,7 +64,14 @@ export function classifyProviderState(input: { // 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 }; + const { permanent, variant } = classifyProviderErrorCode(input.errorCode); + return { + status: 'error', + refetch: () => void input.refetch(), + isRetrying: input.isFetching, + permanent, + variant, + }; } return input.connected ? { status: 'connected' } : { status: 'disconnected' }; } diff --git a/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts b/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts index c9c5ca54d4..f7e4a368a5 100644 --- a/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts +++ b/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts @@ -185,8 +185,9 @@ export function useTrackSecurityAgentInteraction(scope: string) { organizationId: scope, ...vars, }), - onError: error => { - toast.error(error.message); - }, + // Intentionally no onError handler: this is fire-and-forget telemetry that + // pings on nav/tab/toggle. A failure must never surface a user-facing toast — + // it would spam errors and stack on top of real mutation errors. React Query + // captures the rejection internally; we deliberately don't act on it. }); } diff --git a/apps/mobile/src/lib/query-client.test.ts b/apps/mobile/src/lib/query-client.test.ts index 34be0f9fa2..0d092f9c10 100644 --- a/apps/mobile/src/lib/query-client.test.ts +++ b/apps/mobile/src/lib/query-client.test.ts @@ -9,7 +9,9 @@ describe('createKiloAppQueryClient', () => { const signOut = vi.fn(); const clear = setTrpcUnauthorizedHandler(signOut); const queryClient = createKiloAppQueryClient(); - const error = Object.assign(new Error('unauthorized'), { data: { httpStatus: 401 } }); + const error = Object.assign(new Error('unauthorized'), { + data: { authRequired: true, httpStatus: 401 }, + }); const mutation = queryClient.getMutationCache().build(queryClient, { mutationFn: async () => { @@ -28,7 +30,7 @@ describe('createKiloAppQueryClient', () => { const clear = setTrpcUnauthorizedHandler(signOut); const queryClient = createKiloAppQueryClient(); const error = Object.assign(new Error('unauthorized'), { - shape: { data: { httpStatus: 401 } }, + shape: { data: { authRequired: true, httpStatus: 401 } }, }); const mutation = queryClient.getMutationCache().build(queryClient, { diff --git a/apps/mobile/src/lib/trpc-unauthorized.test.ts b/apps/mobile/src/lib/trpc-unauthorized.test.ts index 69a94c66cb..d701633768 100644 --- a/apps/mobile/src/lib/trpc-unauthorized.test.ts +++ b/apps/mobile/src/lib/trpc-unauthorized.test.ts @@ -7,26 +7,36 @@ import { } from './auth/trpc-unauthorized'; describe('tRPC unauthorized handling', () => { - it('recognizes tRPC query errors with HTTP 401 status', () => { - expect(isUnauthorizedTrpcError({ data: { httpStatus: 401 } })).toBe(true); - expect(isUnauthorizedTrpcError({ shape: { data: { httpStatus: 401 } } })).toBe(true); + it('recognizes a context auth failure flagged with data.authRequired (direct + shaped)', () => { + expect(isUnauthorizedTrpcError({ data: { authRequired: true, httpStatus: 401 } })).toBe(true); + expect( + isUnauthorizedTrpcError({ shape: { data: { authRequired: true, httpStatus: 401 } } }) + ).toBe(true); + }); + + it('does NOT treat a bare 401 without authRequired as a session failure', () => { + // Regression guard: a procedure-level UNAUTHORIZED (e.g. org-access denial) + // is also HTTP 401 but must be handled in-screen, not by signing out. + expect(isUnauthorizedTrpcError({ data: { httpStatus: 401 } })).toBe(false); + expect(isUnauthorizedTrpcError({ data: { code: 'UNAUTHORIZED', httpStatus: 401 } })).toBe(false); expect(isUnauthorizedTrpcError({ data: { httpStatus: 403 } })).toBe(false); }); - it('runs the registered sign-out handler for a 401 query error', () => { + it('runs the registered sign-out handler for a context auth failure', () => { const signOut = vi.fn(); const clear = setTrpcUnauthorizedHandler(signOut); - handleTrpcQueryError({ data: { httpStatus: 401 } }); + handleTrpcQueryError({ data: { authRequired: true, httpStatus: 401 } }); expect(signOut).toHaveBeenCalledTimes(1); clear(); }); - it('does not run the handler for non-401 query errors', () => { + it('does not run the handler for a bare 401 or other errors', () => { const signOut = vi.fn(); const clear = setTrpcUnauthorizedHandler(signOut); + handleTrpcQueryError({ data: { httpStatus: 401 } }); handleTrpcQueryError({ data: { httpStatus: 500 } }); expect(signOut).not.toHaveBeenCalled(); diff --git a/apps/web/src/components/security-agent/SecurityDashboard.tsx b/apps/web/src/components/security-agent/SecurityDashboard.tsx index 548b9ceaad..2f587cc9f7 100644 --- a/apps/web/src/components/security-agent/SecurityDashboard.tsx +++ b/apps/web/src/components/security-agent/SecurityDashboard.tsx @@ -992,7 +992,15 @@ function RepositoryActionRow({ }) { const needsActionPercentage = repository.open > 0 ? Math.round((repository.needsAction / repository.open) * 100) : 0; - const complianceTone = repository.slaCompliancePercent < 70 ? 'danger' : 'success'; + // null slaCompliancePercent = no open SLA-tracked findings → "Not measured", + // shown muted rather than as a misleading green 100%. + const isSlaMeasured = repository.slaCompliancePercent != null; + const complianceTone = + repository.slaCompliancePercent != null && repository.slaCompliancePercent < 70 + ? 'danger' + : 'success'; + const slaComplianceLabel = isSlaMeasured ? `${repository.slaCompliancePercent}%` : 'Not measured'; + const complianceTextClass = isSlaMeasured ? toneTextClass(complianceTone) : 'text-muted-foreground'; const actionTone = repository.needsAction > 0 ? 'warning' : 'success'; return ( @@ -1014,16 +1022,14 @@ function RepositoryActionRow({ - {slaEnabled - ? `${repository.slaCompliancePercent}%` - : `${repository.needsAction} findings`} + {slaEnabled ? slaComplianceLabel : `${repository.needsAction} findings`} - {repo.slaCompliancePercent}% + {repo.slaCompliancePercent == null + ? 'Not measured' + : `${repo.slaCompliancePercent}%`} )} diff --git a/apps/web/src/lib/agent-config/db/agent-configs.ts b/apps/web/src/lib/agent-config/db/agent-configs.ts index b2802c30e2..da6116f484 100644 --- a/apps/web/src/lib/agent-config/db/agent-configs.ts +++ b/apps/web/src/lib/agent-config/db/agent-configs.ts @@ -91,7 +91,7 @@ export async function upsertAgentConfig( agent_type: data.agentType, platform: data.platform, config: data.config, - is_enabled: data.isEnabled ?? true, + is_enabled: data.isEnabled ?? false, created_by: data.createdBy, }) .onConflictDoUpdate({ @@ -219,7 +219,7 @@ export async function upsertAgentConfigForOwner( agent_type: data.agentType, platform: data.platform, config: data.config, - is_enabled: data.isEnabled ?? true, + is_enabled: data.isEnabled ?? false, created_by: data.createdBy, } : { @@ -228,7 +228,7 @@ export async function upsertAgentConfigForOwner( agent_type: data.agentType, platform: data.platform, config: data.config, - is_enabled: data.isEnabled ?? true, + is_enabled: data.isEnabled ?? false, created_by: data.createdBy, }; diff --git a/apps/web/src/lib/security-agent/db/dashboard-stats.test.ts b/apps/web/src/lib/security-agent/db/dashboard-stats.test.ts index 8c67e30304..98bab23293 100644 --- a/apps/web/src/lib/security-agent/db/dashboard-stats.test.ts +++ b/apps/web/src/lib/security-agent/db/dashboard-stats.test.ts @@ -135,7 +135,8 @@ describe('getDashboardStats', () => { overdue: 0, exploitable: 0, needsAction: 1, - slaCompliancePercent: 100, + // No open SLA-tracked findings for this repo → "not measured" (null), not a misleading 100%. + slaCompliancePercent: null, }, ]); }); diff --git a/apps/web/src/lib/security-agent/db/dashboard-stats.ts b/apps/web/src/lib/security-agent/db/dashboard-stats.ts index be663fa92b..f279437181 100644 --- a/apps/web/src/lib/security-agent/db/dashboard-stats.ts +++ b/apps/web/src/lib/security-agent/db/dashboard-stats.ts @@ -68,7 +68,8 @@ export type DashboardStats = { overdue: number; exploitable: number; needsAction: number; - slaCompliancePercent: number; + /** null when the repo has no open SLA-tracked findings — "not measured", not 100%. */ + slaCompliancePercent: number | null; }>; repositoryCount: number; }; @@ -169,7 +170,7 @@ type RepoHealthRow = { overdue: string; exploitable: string; needs_action: string; - sla_compliance_percent: string; + sla_compliance_percent: string | null; repository_count: string; }; @@ -383,7 +384,7 @@ export async function getDashboardStats(params: GetDashboardStatsParams): Promis ) ) AS needs_action, CASE - WHEN COUNT(*) FILTER (WHERE ${security_findings.status} = 'open' AND ${security_findings.sla_due_at} IS NOT NULL) = 0 THEN 100 + WHEN COUNT(*) FILTER (WHERE ${security_findings.status} = 'open' AND ${security_findings.sla_due_at} IS NOT NULL) = 0 THEN NULL ELSE ROUND( COUNT(*) FILTER (WHERE ${security_findings.status} = 'open' AND ${security_findings.sla_due_at} IS NOT NULL AND ${security_findings.sla_due_at} > now()) * 100.0 / COUNT(*) FILTER (WHERE ${security_findings.status} = 'open' AND ${security_findings.sla_due_at} IS NOT NULL), 1 @@ -550,7 +551,8 @@ export async function getDashboardStats(params: GetDashboardStatsParams): Promis overdue: Number(row.overdue), exploitable: Number(row.exploitable), needsAction: Number(row.needs_action), - slaCompliancePercent: Number(row.sla_compliance_percent), + slaCompliancePercent: + row.sla_compliance_percent == null ? null : Number(row.sla_compliance_percent), })); const repositoryCount = Number(repoHealthResult.rows[0]?.repository_count ?? 0); diff --git a/apps/web/src/lib/trpc/init.ts b/apps/web/src/lib/trpc/init.ts index a13329eb08..ea1ab94fef 100644 --- a/apps/web/src/lib/trpc/init.ts +++ b/apps/web/src/lib/trpc/init.ts @@ -4,7 +4,7 @@ import { initTRPC, TRPCError } from '@trpc/server'; import type { User } from '@kilocode/db/schema'; import { setTag, trpcMiddleware } from '@sentry/nextjs'; import { userCanManageCredits } from '@/lib/admin/credit-management'; -import { trpcErrorFormatter } from '@/lib/trpc/transport'; +import { AuthContextError, trpcErrorFormatter } from '@/lib/trpc/transport'; export { UpstreamApiError } from '@/lib/trpc/transport'; // Define the context type @@ -21,6 +21,10 @@ export const createTRPCContext = async (): Promise => { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated - no user to set on context', + // Marks this as a genuine session failure (vs a procedure-level + // UNAUTHORIZED like org-access denial) so the mobile client signs out + // only here — see AuthContextError / data.authRequired. + cause: new AuthContextError(), }); } setTag('userId', user.id); diff --git a/apps/web/src/lib/trpc/transport.ts b/apps/web/src/lib/trpc/transport.ts index 64fb533086..6ab7e37076 100644 --- a/apps/web/src/lib/trpc/transport.ts +++ b/apps/web/src/lib/trpc/transport.ts @@ -10,6 +10,21 @@ export class UpstreamApiError extends Error { } } +/** + * Marker for the context-level UNAUTHORIZED (no authenticated user / invalid + * token). The error-formatter surfaces it as `data.authRequired`, letting the + * mobile client sign the user out ONLY on a genuine session failure — and not + * on a procedure-level UNAUTHORIZED (e.g. org-access denial via + * ensureOrganizationAccess), which must be handled in-screen as a permission + * error rather than logging the whole app out. + */ +export class AuthContextError extends Error { + constructor() { + super('auth_context'); + this.name = 'AuthContextError'; + } +} + export const TrpcZodFlattenedErrorSchema = z.object({ formErrors: z.array(z.string()), fieldErrors: z.record(z.string(), z.array(z.string())), @@ -23,6 +38,7 @@ export const TrpcErrorDataSchema = z path: z.string().optional(), zodError: TrpcZodFlattenedErrorSchema.nullable(), upstreamCode: z.string().optional(), + authRequired: z.boolean().optional(), }) .passthrough(); @@ -79,5 +95,6 @@ export const trpcErrorFormatter = (({ shape, error }) => ({ ? z.flattenError(error.cause) : null, upstreamCode: error.cause instanceof UpstreamApiError ? error.cause.upstreamCode : undefined, + authRequired: error.cause instanceof AuthContextError ? true : undefined, }, })) satisfies TRPCErrorFormatter; diff --git a/apps/web/src/routers/code-reviews-router.test.ts b/apps/web/src/routers/code-reviews-router.test.ts index a3b41ec291..1ec2b3ca94 100644 --- a/apps/web/src/routers/code-reviews-router.test.ts +++ b/apps/web/src/routers/code-reviews-router.test.ts @@ -1404,6 +1404,31 @@ describe('review agent config REVIEW.md setting', () => { expect(config?.is_enabled).toBe(false); }); + it('does not enable Code Reviewer when a fresh personal config is saved without isEnabled', async () => { + const caller = await createCallerForUser(testUser.id); + + // No pre-existing agent_configs row: this sub-setting save creates it. + // Regression guard — the insert default used to be `?? true`, which + // silently turned on auto-reviews the moment a user edited any setting. + await caller.personalReviewAgent.saveReviewConfig({ + platform: 'github', + reviewStyle: 'balanced', + focusAreas: [], + modelSlug: 'test-model', + disableReviewMd: true, + }); + + const config = await db.query.agent_configs.findFirst({ + where: and( + eq(agent_configs.agent_type, 'code_review'), + eq(agent_configs.platform, 'github'), + eq(agent_configs.owned_by_user_id, testUser.id) + ), + }); + + expect(config?.is_enabled).toBe(false); + }); + it('preserves personal review feature settings during a full config save', async () => { const caller = await createCallerForUser(testUser.id); await db.insert(agent_configs).values({ diff --git a/apps/web/src/routers/kiloclaw-router.ts b/apps/web/src/routers/kiloclaw-router.ts index 9441f99c48..ab503f2e30 100644 --- a/apps/web/src/routers/kiloclaw-router.ts +++ b/apps/web/src/routers/kiloclaw-router.ts @@ -4597,7 +4597,10 @@ export const kiloclawRouter = createTRPCRouter({ const workerFlag = isDev ? ` --worker-url=${process.env.KILOCLAW_API_URL ?? 'http://localhost:8795'}` : ''; - const gmailPushFlag = isDev ? ' --gmail-push-worker-url=${GMAIL_PUSH_WORKER_URL}' : ''; + const gmailPushFlag = + isDev && process.env.GMAIL_PUSH_WORKER_URL + ? ` --gmail-push-worker-url=${process.env.GMAIL_PUSH_WORKER_URL}` + : ''; const instance = await getActiveInstance(ctx.user.id); const iid = workerInstanceId(instance); const instanceFlag = iid ? ` --instance-id=${iid}` : '';