diff --git a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx index 6301b518b8..1c1fc58ebf 100644 --- a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx @@ -6,6 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { BlurBar } from '@/components/ui/blur-bar'; import { Text } from '@/components/ui/text'; +import { useKiloClawTabVisible } from '@/lib/hooks/use-kiloclaw-tab-visible'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { getEffectiveTabBarHeight, @@ -13,6 +14,7 @@ import { shouldHideTabBar, shouldShowTabLabel, TAB_LABEL_WRAP_FONT_SCALE, + tabAccessibilityLabel, } from '@/lib/tab-bar-layout'; const TAB_BAR_ICON_STYLE = { @@ -61,6 +63,8 @@ export default function TabsLayout() { fontScale, }); const tabIconSize = getTabBarIconSize(fontScale); + const showKiloClawTab = useKiloClawTabVisible(); + const tabCount = showKiloClawTab ? 4 : 3; return ( , tabBarIcon: ({ color, focused }) => ( @@ -103,8 +107,9 @@ export default function TabsLayout() { ( TAB_LABEL_WRAP_FONT_SCALE ? 'Kilo\nClaw' : 'KiloClaw'} @@ -127,7 +132,11 @@ export default function TabsLayout() { name="(2_agents)" options={{ title: 'Agents', - tabBarAccessibilityLabel: 'Agents, tab, 3 of 4', + tabBarAccessibilityLabel: tabAccessibilityLabel( + 'Agents', + showKiloClawTab ? 3 : 2, + tabCount + ), tabBarLabel: ({ focused }) => , tabBarIcon: ({ color, focused }) => ( @@ -143,7 +152,11 @@ export default function TabsLayout() { name="(3_profile)" options={{ title: 'Profile', - tabBarAccessibilityLabel: 'Profile, tab, 4 of 4', + tabBarAccessibilityLabel: tabAccessibilityLabel( + 'Profile', + showKiloClawTab ? 4 : 3, + tabCount + ), tabBarLabel: ({ focused }) => , tabBarIcon: ({ color, focused }) => ( diff --git a/apps/mobile/src/components/home/home-screen.mounted.test.tsx b/apps/mobile/src/components/home/home-screen.mounted.test.tsx new file mode 100644 index 0000000000..9cc14c2e97 --- /dev/null +++ b/apps/mobile/src/components/home/home-screen.mounted.test.tsx @@ -0,0 +1,109 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as src/test/render-with-providers.tsx) */ +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +import { HomeScreen } from '@/components/home/home-screen'; + +const hasSessions = vi.hoisted(() => ({ value: true })); + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); +vi.mock('react-native', () => ({ + RefreshControl: 'RefreshControl', + ScrollView: 'ScrollView', + View: 'View', +})); +vi.mock('react-native-reanimated', () => ({ + default: { View: 'Animated.View' }, + FadeIn: { duration: vi.fn() }, + FadeOut: { duration: vi.fn() }, + LinearTransition: {}, +})); +vi.mock('@/components/home/agent-sessions-section', () => ({ + AgentSessionsSection: 'AgentSessionsSection', + hasDisplayableAgentSessions: () => hasSessions.value, +})); +vi.mock('@/components/home/agents-promo-card', () => ({ + AgentsPromoCard: 'AgentsPromoCard', +})); +vi.mock('@/components/home/greeting', () => ({ + buildTimedGreeting: () => 'Good morning', +})); +vi.mock('@/components/home/new-task-button', () => ({ + NewTaskButton: 'NewTaskButton', +})); +vi.mock('@/components/query-error', () => ({ + QueryError: () => null, +})); +vi.mock('@/components/screen-header', () => ({ + ScreenHeader: () => null, +})); +vi.mock('@/components/tab-screen', () => ({ + TabScreenScrollView: 'ScrollView', +})); +vi.mock('@/components/ui/skeleton', () => ({ + Skeleton: 'Skeleton', +})); +vi.mock('@/lib/hooks/use-agent-sessions', () => ({ + useAgentSessions: () => ({ + activeSessions: [], + isLoading: false, + storedSessions: [{}], + storedIsError: false, + storedIsSuccess: true, + refetch: vi.fn(), + }), +})); +vi.mock('@/lib/organization-context', () => ({ + useOrganization: () => ({ organizationId: 'org-1', isLoaded: true }), +})); + +function nodeCount(root: TestRenderer.ReactTestInstance, type: string): number { + return root.findAll(node => typeof node.type === 'string' && (node.type as string) === type) + .length; +} + +async function mountHome(): Promise { + const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = { + current: undefined, + }; + await act(async () => { + await Promise.resolve(); + rendererRef.current = TestRenderer.create(createElement(HomeScreen)); + }); + const renderer = rendererRef.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +describe('HomeScreen composition', () => { + it('renders the sessions section and new-task button when sessions are present', async () => { + hasSessions.value = true; + const renderer = await mountHome(); + expect(nodeCount(renderer.root, 'AgentSessionsSection')).toBe(1); + expect(nodeCount(renderer.root, 'NewTaskButton')).toBe(1); + expect(nodeCount(renderer.root, 'Skeleton')).toBe(0); + + await act(async () => { + await Promise.resolve(); + renderer.unmount(); + }); + }); + + it('renders only the agents promo card when there are no sessions', async () => { + hasSessions.value = false; + const renderer = await mountHome(); + expect(nodeCount(renderer.root, 'AgentsPromoCard')).toBe(1); + expect(nodeCount(renderer.root, 'AgentSessionsSection')).toBe(0); + expect(nodeCount(renderer.root, 'NewTaskButton')).toBe(0); + + await act(async () => { + await Promise.resolve(); + renderer.unmount(); + }); + }); +}); diff --git a/apps/mobile/src/components/home/home-screen.test.ts b/apps/mobile/src/components/home/home-screen.test.ts index 5ad64e7ed8..a46faf5250 100644 --- a/apps/mobile/src/components/home/home-screen.test.ts +++ b/apps/mobile/src/components/home/home-screen.test.ts @@ -5,12 +5,7 @@ import { HomeScreen } from '@/components/home/home-screen'; vi.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({ invalidateQueries: vi.fn() }), })); -vi.mock('expo-router', () => ({ - useFocusEffect: vi.fn(), - useIsFocused: () => true, -})); vi.mock('react-native', () => ({ - AppState: { addEventListener: vi.fn(() => ({ remove: vi.fn() })) }, RefreshControl: 'RefreshControl', ScrollView: 'ScrollView', View: 'View', @@ -19,9 +14,7 @@ vi.mock('react-native-reanimated', () => ({ default: { View: 'Animated.View' }, FadeIn: { duration: vi.fn() }, FadeOut: { duration: vi.fn() }, -})); -vi.mock('@kilocode/notifications', () => ({ - badgeBucketForInstance: (sandboxId: string) => sandboxId, + LinearTransition: {}, })); vi.mock('@/components/home/agent-sessions-section', () => ({ AgentSessionsSection: () => null, @@ -32,21 +25,9 @@ vi.mock('@/components/home/agents-promo-card', () => ({ vi.mock('@/components/home/greeting', () => ({ buildTimedGreeting: () => 'Good morning', })); -vi.mock('@/components/home/kiloclaw-promo-card', () => ({ - KiloClawPromoCard: () => null, -})); vi.mock('@/components/home/new-task-button', () => ({ NewTaskButton: () => null, })); -vi.mock('@/components/home/section-header', () => ({ - SectionHeader: () => null, -})); -vi.mock('@/components/kiloclaw/instance-card', () => ({ - KiloClawCard: () => null, -})); -vi.mock('@/components/kiloclaw/status-badge', () => ({ - isTransitionalStatus: () => false, -})); vi.mock('@/components/query-error', () => ({ QueryError: () => null, })); @@ -63,30 +44,16 @@ vi.mock('@/components/ui/skeleton', () => ({ vi.mock('@/lib/hooks/use-agent-sessions', () => ({ useAgentSessions: () => ({ activeSessions: [], isLoading: false, storedSessions: [] }), })); -vi.mock('@/lib/hooks/use-instance-context', () => ({ - useAllKiloClawInstances: () => ({ - data: [], - isError: false, - isPending: false, - }), -})); -vi.mock('@/lib/hooks/use-unread-counts', () => ({ - useUnreadCounts: () => ({ byBadgeBucket: new Map() }), -})); vi.mock('@/lib/organization-context', () => ({ useOrganization: () => ({ organizationId: null }), })); -vi.mock('@/lib/trpc', () => ({ - useTRPC: () => ({ - kiloclaw: { - getStatus: { queryKey: () => ['kiloclaw', 'getStatus'] }, - listAllInstances: { queryKey: () => ['kiloclaw', 'listAllInstances'] }, - }, - }), -})); describe('HomeScreen copy', () => { it('does not show the first-time welcome headline on the main page', () => { expect(HomeScreen.toString()).not.toContain('Welcome to Kilo'); }); + + it('renders no KiloClaw surface', () => { + expect(HomeScreen.toString()).not.toContain('KiloClaw'); + }); }); diff --git a/apps/mobile/src/components/home/home-screen.tsx b/apps/mobile/src/components/home/home-screen.tsx index b34e50d320..812ce2ea23 100644 --- a/apps/mobile/src/components/home/home-screen.tsx +++ b/apps/mobile/src/components/home/home-screen.tsx @@ -1,93 +1,29 @@ 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 { useCallback, useState } from 'react'; +import { RefreshControl, View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; import { TabScreenScrollView } from '@/components/tab-screen'; -import { badgeBucketForInstance } from '@kilocode/notifications'; - 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'; 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'; -import { type ClawInstance, useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; -import { useUnreadCounts } from '@/lib/hooks/use-unread-counts'; import { useOrganization } from '@/lib/organization-context'; -import { useTRPC } from '@/lib/trpc'; - -const DEFAULT_LIST_POLL_MS = 30_000; -const TRANSITIONAL_POLL_MS = 5000; - -function pickListPollInterval(instances: ClawInstance[] | undefined): number { - const hasTransitional = (instances ?? []).some(i => isTransitionalStatus(i.status)); - return hasTransitional ? TRANSITIONAL_POLL_MS : DEFAULT_LIST_POLL_MS; -} export function HomeScreen() { const queryClient = useQueryClient(); - const trpc = useTRPC(); - const isFocused = useIsFocused(); const [refreshing, setRefreshing] = useState(false); const { organizationId, isLoaded: orgLoaded } = useOrganization(); - const invalidateHomeQueries = useCallback(() => { - void queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.listAllInstances.queryKey(), - }); - void queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.getStatus.queryKey(), - }); - void queryClient.invalidateQueries({ queryKey: ['kiloclaw-latest-message'] }); - }, [queryClient, trpc.kiloclaw.getStatus, trpc.kiloclaw.listAllInstances]); - - useFocusEffect( - useCallback(() => { - invalidateHomeQueries(); - }, [invalidateHomeQueries]) - ); - - // Foregrounding the app doesn't trigger `useFocusEffect`; cover that case - // with an AppState listener, gated on focus so we don't refetch when Home - // is not the visible tab. - useEffect(() => { - if (!isFocused) { - return undefined; - } - const subscription = AppState.addEventListener('change', nextAppState => { - if (nextAppState === 'active') { - invalidateHomeQueries(); - } - }); - return () => { - subscription.remove(); - }; - }, [isFocused, invalidateHomeQueries]); - - // Upshift polling while any instance is transitional. react-query's - // `refetchInterval` function form re-evaluates after every fetch, so the - // cadence adapts as the list resolves. `getStatus` polling is upshifted - // per card (see `KiloClawCard`). - const { - data: instances, - isPending: instancesPending, - isError: instancesError, - refetch: refetchInstances, - } = useAllKiloClawInstances(pickListPollInterval); - const { byBadgeBucket: unreadByBadgeBucket } = useUnreadCounts(); const { storedSessions, activeSessions, @@ -100,7 +36,7 @@ export function HomeScreen() { enabled: orgLoaded, }); - const isLoading = instancesPending || sessionsLoading || !orgLoaded; + const isLoading = sessionsLoading || !orgLoaded; // Match what the Home Agent-sessions section actually renders (cloud-agent // stored + any active), so a CLI-only account shows the first-use promo @@ -130,12 +66,6 @@ export function HomeScreen() { {isLoading ? ( - - - - - - @@ -146,13 +76,6 @@ export function HomeScreen() { ) : ( - {renderKiloClawSlot({ - instances: instances ?? [], - instancesError, - handleRetryInstances: () => void refetchInstances(), - unreadByBadgeBucket, - })} - {renderSessionsOrPromo({ hasAnySession, organizationId, @@ -174,45 +97,6 @@ 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 ( - - - - {params.instances.map(instance => ( - - ))} - - - ); - } - if (params.instancesError) { - return ( - - ); - } - return ; -} - function renderSessionsOrPromo(params: { hasAnySession: boolean; organizationId: string | null; diff --git a/apps/mobile/src/components/home/kiloclaw-promo-card.tsx b/apps/mobile/src/components/home/kiloclaw-promo-card.tsx deleted file mode 100644 index f4a4dd592a..0000000000 --- a/apps/mobile/src/components/home/kiloclaw-promo-card.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { type Href, useRouter } from 'expo-router'; -import { ChevronRight, MessageSquare } from 'lucide-react-native'; -import { Pressable, View } from 'react-native'; - -import { Text } from '@/components/ui/text'; -import { agentColor } from '@/lib/agent-color'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { cn } from '@/lib/utils'; - -const TITLE = 'KiloClaw'; - -export function KiloClawPromoCard() { - const colors = useThemeColors(); - const router = useRouter(); - const tint = agentColor(TITLE); - - return ( - { - router.push('/(app)/onboarding' as Href); - }} - className="mx-4 gap-3 rounded-2xl border border-border bg-card p-4 active:opacity-80" - accessibilityLabel="Create your KiloClaw agent" - > - - - - - - {TITLE} - - Personal AI assistant - - - - - Create your agent that reads email, manages your calendar, and takes action on your behalf. - - - - Create your agent - - - - - ); -} diff --git a/apps/mobile/src/lib/auth/auth-context.test.tsx b/apps/mobile/src/lib/auth/auth-context.test.tsx index 3fce075494..b55c18c4c4 100644 --- a/apps/mobile/src/lib/auth/auth-context.test.tsx +++ b/apps/mobile/src/lib/auth/auth-context.test.tsx @@ -10,7 +10,9 @@ const hoisted = vi.hoisted(() => { const callOrder: string[] = []; const secureStore = { + getItem: vi.fn().mockReturnValue(null), getItemAsync: vi.fn().mockResolvedValue(null), + setItem: vi.fn().mockReturnValue(undefined), setItemAsync: vi.fn().mockResolvedValue(undefined), // eslint-disable-next-line require-await -- mock returning a resolved promise deleteItemAsync: vi.fn().mockImplementation(async (_key: string) => { @@ -57,7 +59,9 @@ const hoisted = vi.hoisted(() => { // ---- all vi.mock calls ---- vi.mock('expo-secure-store', () => ({ + getItem: hoisted.secureStore.getItem, getItemAsync: hoisted.secureStore.getItemAsync, + setItem: hoisted.secureStore.setItem, setItemAsync: hoisted.secureStore.setItemAsync, deleteItemAsync: hoisted.secureStore.deleteItemAsync, })); @@ -103,6 +107,10 @@ vi.mock('@/lib/last-active-instance', () => ({ clearLastActiveInstance: vi.fn().mockResolvedValue(undefined), })); +// The ownership module is intentionally NOT mocked here: the sign-out gate +// regression test must observe the real gate closing before any await and +// blocking a late persist from calling SecureStore.setItem. + vi.mock('@/lib/kilo-pass/use-store-kilo-pass-purchase', () => ({ resetPurchaseErrorToastDedup: vi.fn(), })); @@ -117,6 +125,7 @@ vi.mock('@/lib/pr-review/viewed-files', () => ({ vi.mock('@/lib/storage-keys', () => ({ AUTH_TOKEN_KEY: 'auth-token', + KILOCLAW_OWNED_KEY: 'kiloclaw-owned', LEGACY_EXCHANGE_DONE_KEY: 'legacy-exchange-done', NOTIFICATION_PROMPT_SEEN_KEY: 'notification-prompt-seen', ORGANIZATION_STORAGE_KEY: 'organization', @@ -279,4 +288,33 @@ describe('sign-out teardown ordering', () => { unmount(); }); + + it('closes the ownership gate before any await and blocks a late persist', async () => { + const { ctx, unmount } = await mountAndGetContext(); + const ownership = await import('@/lib/kiloclaw-tab-ownership'); + + // The old account's tab layout already resolved and persisted ownership. + ownership.persistKiloClawOwned(true); + expect(hoisted.secureStore.setItem).toHaveBeenCalledTimes(1); + + // Sign-out starts; the gate closes synchronously at the first line, + // before the first await, while the teardown awaits are still in flight. + const signOutPromise = ctx.signOut(); + + // A late list reconcile from the old observer runs before + // clearKiloClawOwned is reached. + ownership.persistKiloClawOwned(false); + + await act(async () => { + await signOutPromise; + }); + + // The late persist could not call SecureStore.setItem; only the + // pre-sign-out write happened, and the clear step still deleted the key. + expect(hoisted.secureStore.setItem).toHaveBeenCalledTimes(1); + expect(hoisted.secureStore.setItem).toHaveBeenCalledWith('kiloclaw-owned', '1'); + expect(hoisted.secureStore.deleteItemAsync).toHaveBeenCalledWith('kiloclaw-owned'); + + unmount(); + }); }); diff --git a/apps/mobile/src/lib/auth/auth-context.tsx b/apps/mobile/src/lib/auth/auth-context.tsx index 53f9d8230c..494754358a 100644 --- a/apps/mobile/src/lib/auth/auth-context.tsx +++ b/apps/mobile/src/lib/auth/auth-context.tsx @@ -20,6 +20,7 @@ import { setTrpcUnauthorizedHandler } from '@/lib/auth/trpc-unauthorized'; import { exchangeLegacyToken } from '@/lib/auth/exchange-legacy-token'; import { clearAgentModelPreference } from '@/lib/hooks/use-persisted-agent-model'; import { clearReasoningPreference } from '@/lib/hooks/use-reasoning-preference'; +import { clearKiloClawOwned, gateKiloClawOwned } from '@/lib/kiloclaw-tab-ownership'; import { clearLastActiveInstance } from '@/lib/last-active-instance'; import { resetPurchaseErrorToastDedup } from '@/lib/kilo-pass/use-store-kilo-pass-purchase'; import { clearRecentPrs } from '@/lib/pr-review/recent-prs'; @@ -244,8 +245,9 @@ export function AuthProvider({ children }: { readonly children: ReactNode }) { const signOut = useCallback(async (ended = false) => { isSignedOutReference.current = true; invalidateRefreshSession(); - // Synchronous gate close — must happen before any await so capture - // is denied for the entire async teardown window. + // Close ownership persistence before any await so a late list response + // cannot write the previous account's answer during teardown. + gateKiloClawOwned(); clearTelemetryDecision(); Sentry.setUser(null); // SDK teardown — drop queues, do not flush them. Must happen before @@ -266,6 +268,7 @@ export function AuthProvider({ children }: { readonly children: ReactNode }) { await SecureStore.deleteItemAsync(SESSION_FILTERS_KEY); await SecureStore.deleteItemAsync(NOTIFICATION_PROMPT_SEEN_KEY); await clearLastActiveInstance(); + await clearKiloClawOwned(); await clearRecentPrs(); await clearViewedFiles(); clearAgentModelPreference(); diff --git a/apps/mobile/src/lib/hooks/use-instance-context.ts b/apps/mobile/src/lib/hooks/use-instance-context.ts index e9ad5aca9f..7aa0bd0e9b 100644 --- a/apps/mobile/src/lib/hooks/use-instance-context.ts +++ b/apps/mobile/src/lib/hooks/use-instance-context.ts @@ -10,18 +10,12 @@ import { useTRPC } from '@/lib/trpc'; export type { ClawInstance, InstanceContextResult }; -type ListPollDecider = (instances: ClawInstance[] | undefined) => number; - -export function useAllKiloClawInstances(refetchInterval: number | ListPollDecider = 30_000) { +export function useAllKiloClawInstances(refetchInterval: number | false = 30_000) { const trpc = useTRPC(); - const intervalOption = - typeof refetchInterval === 'function' - ? (query: { state: { data?: ClawInstance[] } }) => refetchInterval(query.state.data) - : refetchInterval; return useQuery( trpc.kiloclaw.listAllInstances.queryOptions(undefined, { staleTime: 30_000, - refetchInterval: intervalOption, + refetchInterval, }) ); } diff --git a/apps/mobile/src/lib/hooks/use-kiloclaw-tab-visible.ts b/apps/mobile/src/lib/hooks/use-kiloclaw-tab-visible.ts new file mode 100644 index 0000000000..7cdbce37f3 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-kiloclaw-tab-visible.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from 'react'; + +import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; +import { persistKiloClawOwned, readKiloClawOwned } from '@/lib/kiloclaw-tab-ownership'; + +/** + * Whether the KiloClaw tab entry is shown. Seeded from the persisted answer so + * the tab count is correct on the first frame, then reconciled from the + * instance list. A failed or pending fetch keeps the persisted answer. + * + * The poll is off: this hook is mounted for the whole signed-in session, and + * ownership changes rarely. The list still refreshes on a cold start, on a Home + * pull-to-refresh, and from the KiloClaw tab's own poll and invalidations. + */ +export function useKiloClawTabVisible(): boolean { + const { data: instances } = useAllKiloClawInstances(false); + const [visible, setVisible] = useState(readKiloClawOwned); + + useEffect(() => { + if (instances === undefined) { + return; + } + const owned = instances.length > 0; + persistKiloClawOwned(owned); + setVisible(owned); + }, [instances]); + + return visible; +} diff --git a/apps/mobile/src/lib/kiloclaw-tab-ownership.test.ts b/apps/mobile/src/lib/kiloclaw-tab-ownership.test.ts new file mode 100644 index 0000000000..370797caf8 --- /dev/null +++ b/apps/mobile/src/lib/kiloclaw-tab-ownership.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getItem: vi.fn<() => string | null>(), + setItem: vi.fn<(key: string, value: string) => void>(), + deleteItemAsync: vi.fn<(key: string) => Promise>(), +})); + +vi.mock('expo-secure-store', () => ({ + getItem: mocks.getItem, + setItem: mocks.setItem, + deleteItemAsync: mocks.deleteItemAsync, +})); + +vi.mock('@/lib/storage-keys', () => ({ + KILOCLAW_OWNED_KEY: 'kiloclaw-owned', +})); + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); +}); + +describe('kiloclaw tab ownership', () => { + it('reads the native store once and reports true for a stored "1"', async () => { + mocks.getItem.mockReturnValue('1'); + const { readKiloClawOwned } = await import('./kiloclaw-tab-ownership'); + + expect(readKiloClawOwned()).toBe(true); + expect(readKiloClawOwned()).toBe(true); + expect(mocks.getItem).toHaveBeenCalledTimes(1); + }); + + it('reports false when the native read throws', async () => { + mocks.getItem.mockImplementation(() => { + throw new Error('native store unavailable'); + }); + const { readKiloClawOwned } = await import('./kiloclaw-tab-ownership'); + + expect(readKiloClawOwned()).toBe(false); + }); + + it('writes only a changed answer', async () => { + mocks.setItem.mockReturnValue(undefined); + const { persistKiloClawOwned } = await import('./kiloclaw-tab-ownership'); + + persistKiloClawOwned(true); + expect(mocks.setItem).toHaveBeenCalledTimes(1); + expect(mocks.setItem).toHaveBeenCalledWith('kiloclaw-owned', '1'); + + persistKiloClawOwned(true); + expect(mocks.setItem).toHaveBeenCalledTimes(1); + }); + + it('clears the key and keeps the answer false without rereading the store', async () => { + mocks.getItem.mockReturnValue('1'); + mocks.deleteItemAsync.mockResolvedValue(undefined); + const { clearKiloClawOwned, readKiloClawOwned } = await import('./kiloclaw-tab-ownership'); + + expect(readKiloClawOwned()).toBe(true); + mocks.getItem.mockClear(); + + await clearKiloClawOwned(); + + expect(mocks.deleteItemAsync).toHaveBeenCalledWith('kiloclaw-owned'); + expect(readKiloClawOwned()).toBe(false); + expect(mocks.getItem).not.toHaveBeenCalled(); + }); + + it('orders the synchronous write before the sign-out delete and writes nothing after', async () => { + mocks.setItem.mockReturnValue(undefined); + mocks.deleteItemAsync.mockResolvedValue(undefined); + const { clearKiloClawOwned, persistKiloClawOwned, readKiloClawOwned } = + await import('./kiloclaw-tab-ownership'); + + persistKiloClawOwned(true); + await clearKiloClawOwned(); + + expect(mocks.setItem).toHaveBeenCalledWith('kiloclaw-owned', '1'); + expect(mocks.deleteItemAsync).toHaveBeenCalledWith('kiloclaw-owned'); + const setCallOrder = mocks.setItem.mock.invocationCallOrder[0]; + const deleteCallOrder = mocks.deleteItemAsync.mock.invocationCallOrder[0]; + expect(setCallOrder).toBeDefined(); + expect(deleteCallOrder).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by expect above + expect(setCallOrder!).toBeLessThan(deleteCallOrder!); + expect(readKiloClawOwned()).toBe(false); + expect(mocks.setItem).toHaveBeenCalledTimes(1); + expect(mocks.deleteItemAsync).toHaveBeenCalledTimes(1); + }); + + it('blocks a late list reconcile from writing after sign-out begins', async () => { + mocks.setItem.mockReturnValue(undefined); + mocks.deleteItemAsync.mockResolvedValue(undefined); + const { clearKiloClawOwned, persistKiloClawOwned, readKiloClawOwned } = + await import('./kiloclaw-tab-ownership'); + + // The old account's resolved answer is already persisted. + persistKiloClawOwned(true); + expect(mocks.setItem).toHaveBeenCalledTimes(1); + + // Sign-out starts; the delete is pending while the old tab layout's + // observer reconciles a late list response. + const clearing = clearKiloClawOwned(); + persistKiloClawOwned(true); + await clearing; + + // A late response after the delete finishes stays blocked too. + persistKiloClawOwned(false); + + expect(mocks.setItem).toHaveBeenCalledTimes(1); + expect(mocks.deleteItemAsync).toHaveBeenCalledTimes(1); + expect(readKiloClawOwned()).toBe(false); + }); + + it('gateKiloClawOwned blocks persistence without resetting memory or deleting the key', async () => { + mocks.setItem.mockReturnValue(undefined); + mocks.deleteItemAsync.mockResolvedValue(undefined); + const { gateKiloClawOwned, persistKiloClawOwned, readKiloClawOwned } = + await import('./kiloclaw-tab-ownership'); + + // The old account's resolved answer is already persisted. + persistKiloClawOwned(true); + expect(mocks.setItem).toHaveBeenCalledTimes(1); + + // Sign-out starts; the gate closes synchronously, before any await. + gateKiloClawOwned(); + + // A late list reconcile cannot write while the gate is closed. + persistKiloClawOwned(false); + expect(mocks.setItem).toHaveBeenCalledTimes(1); + + // The gate itself neither resets memory nor deletes the native key; + // clearKiloClawOwned remains responsible for those. + expect(mocks.deleteItemAsync).not.toHaveBeenCalled(); + expect(readKiloClawOwned()).toBe(true); + }); + + it('lets the next signed-in account persist after reading the cleared state', async () => { + mocks.setItem.mockReturnValue(undefined); + mocks.deleteItemAsync.mockResolvedValue(undefined); + const { clearKiloClawOwned, persistKiloClawOwned, readKiloClawOwned } = + await import('./kiloclaw-tab-ownership'); + + persistKiloClawOwned(true); + await clearKiloClawOwned(); + + // The next account's tab layout mount reads the cleared answer. + expect(readKiloClawOwned()).toBe(false); + + // Its list resolves and the resolved answer persists again. + persistKiloClawOwned(true); + + expect(mocks.setItem).toHaveBeenCalledWith('kiloclaw-owned', '1'); + expect(mocks.setItem).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/mobile/src/lib/kiloclaw-tab-ownership.ts b/apps/mobile/src/lib/kiloclaw-tab-ownership.ts new file mode 100644 index 0000000000..cbbc5409e9 --- /dev/null +++ b/apps/mobile/src/lib/kiloclaw-tab-ownership.ts @@ -0,0 +1,62 @@ +import * as SecureStore from 'expo-secure-store'; + +import { KILOCLAW_OWNED_KEY } from '@/lib/storage-keys'; + +// The tab bar must decide how many tabs to render on its first frame, before +// the instance list resolves, or the bar visibly shifts once the fetch lands. +// The answer is therefore cached in SecureStore and read synchronously. +let cached: boolean | undefined = undefined; + +// Sign-out deletes the key while the old tab layout observer is still mounted, +// and a late list response in that teardown window could write the previous +// account's answer back. The lock blocks persistence until the next signed-in +// account's tab layout mounts and reads the cleared state. +let persistenceLocked = false; + +export function readKiloClawOwned(): boolean { + if (cached === undefined) { + try { + cached = SecureStore.getItem(KILOCLAW_OWNED_KEY) === '1'; + } catch { + cached = false; + } + } + // The read happens on tab layout mount, which in practice is the next + // signed-in account, so reopening persistence here cannot unblock a stale + // observer that only ever calls persist. + persistenceLocked = false; + return cached; +} + +// The write is synchronous so no write can still be in flight when sign-out +// deletes the key. An async write could land after the delete and leak the +// previous account's answer into the next account's first frame. +export function persistKiloClawOwned(owned: boolean): void { + if (persistenceLocked) { + return; + } + if (cached === owned) { + return; + } + cached = owned; + try { + SecureStore.setItem(KILOCLAW_OWNED_KEY, owned ? '1' : '0'); + } catch { + // A failed write only costs the next cold start its correct first frame. + } +} + +// Sign-out must close the gate synchronously, at its first line, before any +// teardown await, so a late list response from the old tab layout observer +// cannot write the previous account's answer while the awaits are in flight. +// The cached answer and the native key are left untouched here; +// clearKiloClawOwned resets those once the teardown awaits have completed. +export function gateKiloClawOwned(): void { + persistenceLocked = true; +} + +export async function clearKiloClawOwned(): Promise { + gateKiloClawOwned(); + cached = false; + await SecureStore.deleteItemAsync(KILOCLAW_OWNED_KEY); +} diff --git a/apps/mobile/src/lib/storage-keys.ts b/apps/mobile/src/lib/storage-keys.ts index b8416f4f4d..5b76a09ee6 100644 --- a/apps/mobile/src/lib/storage-keys.ts +++ b/apps/mobile/src/lib/storage-keys.ts @@ -18,6 +18,7 @@ export const REVIEW_REQUESTED_AT_KEY = 'store-review-requested-at'; export const PR_REVIEW_RECENTS_KEY = 'pr-review-recents'; export const PR_REVIEW_VIEWED_KEY = 'pr-review-viewed'; export const THEME_PREFERENCE_KEY = 'theme-preference'; +export const KILOCLAW_OWNED_KEY = 'kiloclaw-owned'; export const REFRESH_TOKEN_KEY = 'auth-refresh-token'; export const TOKEN_EXPIRES_AT_KEY = 'auth-token-expires-at'; export const LEGACY_EXCHANGE_DONE_KEY = 'auth-legacy-exchange-done'; diff --git a/apps/mobile/src/lib/tab-bar-layout.test.ts b/apps/mobile/src/lib/tab-bar-layout.test.ts index 5d7babe74a..88e1eb4e47 100644 --- a/apps/mobile/src/lib/tab-bar-layout.test.ts +++ b/apps/mobile/src/lib/tab-bar-layout.test.ts @@ -9,6 +9,7 @@ import { shouldShowTabLabel, TAB_ICON_FORWARD_FONT_SCALE, TAB_LABEL_WRAP_FONT_SCALE, + tabAccessibilityLabel, } from '@/lib/tab-bar-layout'; describe('getTabBarOverlayHeight', () => { @@ -130,3 +131,13 @@ describe('shouldHideTabBar', () => { expect(shouldHideTabBar('/security-agent/personal/findings')).toBe(false); }); }); + +describe('tabAccessibilityLabel', () => { + it('reports the position and total for four tabs', () => { + expect(tabAccessibilityLabel('Home', 1, 4)).toBe('Home, tab, 1 of 4'); + }); + + it('reports the position and total for three tabs', () => { + expect(tabAccessibilityLabel('Profile', 3, 3)).toBe('Profile, tab, 3 of 3'); + }); +}); diff --git a/apps/mobile/src/lib/tab-bar-layout.ts b/apps/mobile/src/lib/tab-bar-layout.ts index 9e95d8810d..b32394c81c 100644 --- a/apps/mobile/src/lib/tab-bar-layout.ts +++ b/apps/mobile/src/lib/tab-bar-layout.ts @@ -83,3 +83,11 @@ export function shouldHideTabBar(pathname: string): boolean { parts[0] === 'security-agent' && parts.length === 3 && parts[2] === 'filter'; return isKiloClawInstancePicker || isSecurityFindingFilter; } + +/** + * Accessibility label for a tab bar entry. The position and the total must match + * the rendered tab count, which changes when the KiloClaw tab is hidden. + */ +export function tabAccessibilityLabel(name: string, position: number, total: number): string { + return `${name}, tab, ${position} of ${total}`; +}