From 6379a2e9a300879f75216fd4e3cf1a8e106ed256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 28 Aug 2026 23:20:58 +0200 Subject: [PATCH 1/6] fix(mobile): confirm reachability before showing offline --- .../offline-banner.mounted.test.tsx | 289 +++++++++++--- .../settings-overview-screen.tsx | 3 +- .../use-offline-banner-state.mounted.test.tsx | 186 ++++++--- .../src/lib/hooks/use-offline-banner-state.ts | 14 +- .../src/lib/offline-banner-state.test.ts | 360 ++++++++++-------- apps/mobile/src/lib/offline-banner-state.ts | 44 ++- 6 files changed, 612 insertions(+), 284 deletions(-) diff --git a/apps/mobile/src/components/offline-banner.mounted.test.tsx b/apps/mobile/src/components/offline-banner.mounted.test.tsx index 5e7475ee8a..333d8ae06a 100644 --- a/apps/mobile/src/components/offline-banner.mounted.test.tsx +++ b/apps/mobile/src/components/offline-banner.mounted.test.tsx @@ -1,19 +1,30 @@ -/* 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 fixed-part-row.mounted.test.tsx) */ -import { createElement } from 'react'; +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer mounts React/RN trees without a DOM */ +import { createElement, type ReactElement, type ReactNode, useSyncExternalStore } from 'react'; import TestRenderer, { act } from 'react-test-renderer'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import '@/i18n'; +import { type ConnectivityState } from '@/lib/connectivity-online'; +import { createOfflineBannerStore, type OfflineBannerStore } from '@/lib/offline-banner-state'; import { OfflineBanner } from './offline-banner'; +import { SettingsOverviewScreen } from './security-agent/settings-overview-screen'; -// ── Hoisted mocks ────────────────────────────────────────────────────────── - -const state = vi.hoisted(() => ({ isOffline: false })); +const state = vi.hoisted(() => ({ store: undefined as OfflineBannerStore | undefined })); const announceForA11y = vi.hoisted(() => vi.fn()); - -vi.mock('react-native', () => ({ - View: 'View', +const settingsConfig = vi.hoisted(() => ({ + data: undefined as unknown, + isLoading: false, + isError: false, + fetchStatus: 'paused', + refetch: vi.fn(), })); +const offlineState: ConnectivityState = { isConnected: true, isInternetReachable: false }; +const onlineState: ConnectivityState = { isConnected: true, isInternetReachable: true }; +const probe = vi.fn<() => Promise>(); +const renderers: TestRenderer.ReactTestRenderer[] = []; +let sourceListener: ((value: ConnectivityState) => void) | undefined = undefined; + +vi.mock('react-native', () => ({ View: 'View', Switch: 'Switch' })); vi.mock('react-native-reanimated', () => ({ default: { View: 'Animated.View' }, FadeIn: { duration: () => ({}) }, @@ -23,75 +34,166 @@ vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 47, right: 0, bottom: 34, left: 0 }), })); vi.mock('@/components/ui/icons', () => ({ + AlertCircle: 'AlertCircle', + Bell: 'Bell', + Clock: 'Clock', + Cpu: 'Cpu', + FolderGit2: 'FolderGit2', + Lock: 'Lock', + SearchX: 'SearchX', + ServerCrash: 'ServerCrash', WifiOff: 'WifiOff', + Zap: 'Zap', })); -vi.mock('@/components/ui/text', () => ({ - Text: 'Text', -})); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({ warnForeground: '#FFFFFF' }), })); -vi.mock('@/lib/a11y/announce', () => ({ - announceForA11y, -})); +vi.mock('@/lib/a11y/announce', () => ({ announceForA11y })); vi.mock('@/lib/hooks/use-offline-banner-state', () => ({ - useOfflineBannerState: () => state.isOffline, + useOfflineBannerState: () => { + if (!state.store) { + throw new Error('Missing test store'); + } + return useSyncExternalStore(state.store.subscribe, state.store.isOffline); + }, + useCommittedConnectivityStatus: () => { + if (!state.store) { + throw new Error('Missing test store'); + } + return useSyncExternalStore(state.store.subscribe, state.store.state); + }, +})); +vi.mock('expo-haptics', () => ({ selectionAsync: vi.fn() })); +vi.mock('expo-router', () => ({ useRouter: () => ({ push: vi.fn() }) })); +vi.mock('@/lib/hooks/use-security-agent', () => ({ + useSecurityAgentConfig: () => settingsConfig, + useSecurityAgentCapability: () => ({ status: 'allowed', canManage: true }), + useSecurityAgentRepositories: () => ({ data: [], isLoading: false, isError: false }), + useSetSecurityAgentEnabled: () => ({ mutate: vi.fn(), isPending: false }), + useTrackSecurityAgentInteraction: () => ({ mutate: vi.fn() }), +})); +vi.mock('@/lib/security-agent', () => ({ getSecurityAgentPath: vi.fn() })); +vi.mock('@/components/security-agent/audit-report-button', () => ({ + AuditReportButton: 'AuditReportButton', +})); +vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); +vi.mock('@/components/ui/configure-row', () => ({ ConfigureRow: 'ConfigureRow' })); +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/accessible-status', () => ({ AccessibleStatus: 'AccessibleStatus' })); +vi.mock('@/components/empty-state', () => ({ + EmptyState: ({ description, action }: { description?: ReactNode; action?: ReactNode }) => + createElement('EmptyState', null, description, action), +})); +vi.mock('@/components/tab-screen', () => ({ + TabScreenScrollView: 'TabScreenScrollView', + useTabBarBottomPadding: () => 0, })); -// ── Helpers ──────────────────────────────────────────────────────────────── - -async function mountBanner(): Promise { +async function mountTree( + element: ReactElement = createElement(OfflineBanner) +): Promise { const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined, }; await act(async () => { await Promise.resolve(); - rendererRef.current = TestRenderer.create(createElement(OfflineBanner)); + rendererRef.current = TestRenderer.create(element); }); const renderer = rendererRef.current; if (!renderer) { throw new Error('renderer was not created'); } + renderers.push(renderer); return renderer; } -function findHost( - root: TestRenderer.ReactTestInstance, - type: string -): TestRenderer.ReactTestInstance[] { +function findHost(root: TestRenderer.ReactTestInstance, type: string) { return root.findAll(node => node.type === type); } -describe('OfflineBanner mounted', () => { +function emit(value: ConnectivityState) { + act(() => sourceListener?.(value)); +} + +async function advanceBy(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +describe('OfflineBanner mounted with confirmed connectivity', () => { beforeEach(() => { - state.isOffline = false; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + vi.useFakeTimers(); + probe.mockReset().mockResolvedValue(false); announceForA11y.mockClear(); + settingsConfig.data = undefined; + settingsConfig.refetch.mockReset(); + state.store = createOfflineBannerStore({ + source: { + subscribe: listener => { + sourceListener = listener; + return () => { + sourceListener = undefined; + }; + }, + }, + timer: { + set(callback, delayMs) { + const timer = setTimeout(callback, delayMs); + return { + cancel: () => { + clearTimeout(timer); + }, + }; + }, + }, + probe, + }); + }); + afterEach(() => { + act(() => { + for (const renderer of renderers.splice(0)) { + renderer.unmount(); + } + }); + state.store?.destroy(); + state.store = undefined; + vi.clearAllTimers(); + vi.useRealTimers(); }); - it('renders nothing while online and never announces', async () => { - const renderer = await mountBanner(); - + it('renders nothing on unknown boot or online and never announces either state', async () => { + const renderer = await mountTree(); + expect(renderer.toJSON()).toBeNull(); + emit(onlineState); expect(renderer.toJSON()).toBeNull(); expect(announceForA11y).not.toHaveBeenCalled(); }); - it('renders the banner on an offline mount without announcing the initial state', async () => { - state.isOffline = true; - const renderer = await mountBanner(); - + it('renders confirmed offline on mount without announcing the initial state', async () => { + emit(offlineState); + await advanceBy(5000); + const renderer = await mountTree(); expect(findHost(renderer.root, 'Animated.View')).toHaveLength(1); - expect(findHost(renderer.root, 'Text')).toHaveLength(1); + expect(findHost(renderer.root, 'Text')[0]?.props.children).toBe('No internet connection'); expect(announceForA11y).not.toHaveBeenCalled(); }); - it('shows the banner with touch transparency, safe-area top, alert semantics, and announces on the online→offline transition', async () => { - const renderer = await mountBanner(); - state.isOffline = true; + it('shows only confirmed offline with touch transparency, safe-area top, and alert semantics', async () => { + const confirmation = Promise.withResolvers(); + probe.mockReturnValue(confirmation.promise); + const renderer = await mountTree(); + emit(offlineState); + await advanceBy(5000); + expect(renderer.toJSON()).toBeNull(); + expect(announceForA11y).not.toHaveBeenCalled(); await act(async () => { - renderer.update(createElement(OfflineBanner)); + confirmation.resolve(false); await Promise.resolve(); }); - expect(findHost(renderer.root, 'Text')[0]?.props.children).toBe('No internet connection'); const outer = findHost(renderer.root, 'View')[0]; expect(outer?.props.pointerEvents).toBe('none'); @@ -100,28 +202,111 @@ describe('OfflineBanner mounted', () => { expect(alert?.props.accessibilityRole).toBe('alert'); expect(alert?.props.accessibilityLabel).toBe('No internet connection'); expect(findHost(renderer.root, 'WifiOff')).toHaveLength(1); - expect(announceForA11y).toHaveBeenCalledTimes(1); - expect(announceForA11y).toHaveBeenCalledWith('No internet connection'); + expect(announceForA11y).toHaveBeenCalledExactlyOnceWith('No internet connection'); + }); + + it('hides immediately and announces restoration only after confirmed offline', async () => { + const renderer = await mountTree(); + emit(offlineState); + await advanceBy(5000); + expect(findHost(renderer.root, 'Animated.View')).toHaveLength(1); + emit(onlineState); + expect(renderer.toJSON()).toBeNull(); + expect(announceForA11y).toHaveBeenCalledTimes(2); + expect(announceForA11y).toHaveBeenNthCalledWith(1, 'No internet connection'); + expect(announceForA11y).toHaveBeenNthCalledWith(2, 'Internet connection restored'); }); - it('hides the banner and announces restoration on the offline→online transition', async () => { - const renderer = await mountBanner(); - state.isOffline = true; + it.each(['cold open', 'resume'])( + 'keeps a false offline report hidden and silent on %s', + async sequence => { + probe.mockResolvedValue(true); + emit(sequence === 'cold open' ? offlineState : onlineState); + const renderer = await mountTree(); + if (sequence === 'resume') { + emit(offlineState); + } + await advanceBy(9000); + expect(renderer.toJSON()).toBeNull(); + expect(announceForA11y).not.toHaveBeenCalled(); + expect(probe).toHaveBeenCalledTimes(1); + } + ); + + it('does not show or announce a stale rejection after recovery during confirmation', async () => { + const confirmation = Promise.withResolvers(); + probe.mockReturnValue(confirmation.promise); + const renderer = await mountTree(); + emit(offlineState); + await advanceBy(5000); + emit(onlineState); await act(async () => { - renderer.update(createElement(OfflineBanner)); + confirmation.reject(new Error('Late transport failure')); await Promise.resolve(); }); - expect(findHost(renderer.root, 'Animated.View')).toHaveLength(1); + expect(renderer.toJSON()).toBeNull(); + expect(announceForA11y).not.toHaveBeenCalled(); + }); + + it.each([true, false])( + 'keeps cached settings usable before and after probe reachability is %s', + async reachable => { + settingsConfig.data = { + isEnabled: true, + repositorySelectionMode: 'selected', + selectedRepositoryIds: [1], + analysisMode: 'auto', + }; + probe.mockResolvedValue(reachable); + const screen = await mountTree(createElement(SettingsOverviewScreen, { scope: 'personal' })); + expect(findHost(screen.root, 'Switch')[0]?.props.value).toBe(true); + + emit(offlineState); + await advanceBy(5000); + expect(findHost(screen.root, 'Switch')[0]?.props.value).toBe(true); + expect(findHost(screen.root, 'AccessibleStatus')).toHaveLength(0); + expect(findHost(screen.root, 'Skeleton')).toHaveLength(0); + } + ); + + it('exposes settings Retry after a successful probe leaves an uncached query paused', async () => { + const confirmation = Promise.withResolvers(); + probe.mockReturnValue(confirmation.promise); + const banner = await mountTree(); + const screen = await mountTree(createElement(SettingsOverviewScreen, { scope: 'personal' })); + expect(findHost(screen.root, 'Skeleton')).toHaveLength(3); + expect(findHost(screen.root, 'Button')).toHaveLength(0); + expect(banner.toJSON()).toBeNull(); - state.isOffline = false; + emit(offlineState); + await advanceBy(5000); + expect(findHost(screen.root, 'Skeleton')).toHaveLength(3); + expect(findHost(screen.root, 'Button')).toHaveLength(0); await act(async () => { - renderer.update(createElement(OfflineBanner)); + confirmation.resolve(true); await Promise.resolve(); }); - expect(renderer.toJSON()).toBeNull(); - expect(announceForA11y).toHaveBeenCalledTimes(2); - expect(announceForA11y).toHaveBeenNthCalledWith(1, 'No internet connection'); - expect(announceForA11y).toHaveBeenNthCalledWith(2, 'Internet connection restored'); + expect(findHost(screen.root, 'Skeleton')).toHaveLength(0); + expect(findHost(screen.root, 'AccessibleStatus')[0]?.props.message).toBe( + 'Could not load Security Agent settings' + ); + const retry = findHost(screen.root, 'Button')[0]; + expect(retry?.props.accessibilityLabel).toBe('Retry'); + expect(banner.toJSON()).toBeNull(); + settingsConfig.refetch.mockImplementationOnce(() => { + settingsConfig.data = { + isEnabled: true, + repositorySelectionMode: 'selected', + selectedRepositoryIds: [1], + analysisMode: 'auto', + }; + screen.update(createElement(SettingsOverviewScreen, { scope: 'personal' })); + }); + const onRetry = retry?.props.onPress as (() => void) | undefined; + act(() => onRetry?.()); + expect(findHost(screen.root, 'Switch')[0]?.props.value).toBe(true); + expect(findHost(screen.root, 'AccessibleStatus')).toHaveLength(0); + expect(banner.toJSON()).toBeNull(); }); }); 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 a09248cf9c..a3802999d4 100644 --- a/apps/mobile/src/components/security-agent/settings-overview-screen.tsx +++ b/apps/mobile/src/components/security-agent/settings-overview-screen.tsx @@ -89,9 +89,10 @@ export function SettingsOverviewScreen({ trackRef.current({ interaction: 'settings_config_viewed' }); }, []); + // A successful probe can confirm online while NetInfo still pauses queries. if ( !config.data && - (config.isError || (config.fetchStatus === 'paused' && committedConnectivity === 'offline')) + (config.isError || (config.fetchStatus === 'paused' && committedConnectivity !== 'unknown')) ) { return ( { const listeners = new Set<(state: ConnectivityState) => void>(); + let current: ConnectivityState | undefined = undefined; return { listeners, addEventListener: (listener: (state: ConnectivityState) => void): (() => void) => { listeners.add(listener); + if (current) { + listener(current); + } return () => { listeners.delete(listener); }; }, emit: (state: ConnectivityState): void => { + current = state; for (const listener of listeners) { listener(state); } }, + reset: () => { + current = undefined; + listeners.clear(); + }, }; }); vi.mock('@react-native-community/netinfo', () => ({ addEventListener: netinfo.addEventListener, })); +vi.mock('@/lib/config', () => ({ API_BASE_URL: 'https://configured-api.example.test' })); +const fetchMock = vi.fn(); +const renderers: TestRenderer.ReactTestRenderer[] = []; type Hooks = { useOfflineBannerState: () => boolean; useCommittedConnectivityStatus: () => BannerState; @@ -47,10 +60,7 @@ function Probe({ hooks }: { hooks: Hooks }) { function textChildren(renderer: TestRenderer.ReactTestRenderer): string[] | null { const json = renderer.toJSON(); - if (!json) { - return null; - } - if (Array.isArray(json)) { + if (!json || Array.isArray(json)) { return null; } return json.children?.filter((child): child is string => typeof child === 'string') ?? null; @@ -68,94 +78,172 @@ async function renderProbe(hooks: Hooks): Promise { + await vi.advanceTimersByTimeAsync(ms); + }); +} + describe('useOfflineBannerState and useCommittedConnectivityStatus mounted', () => { beforeEach(() => { - // React 19 requires the act environment flag before `act` supports - // updates scheduled from effects and external stores. (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - // A fresh module per test gives a fresh module-level store singleton. vi.resetModules(); - netinfo.listeners.clear(); + netinfo.reset(); + vi.useFakeTimers(); + fetchMock.mockReset().mockRejectedValue(new TypeError('Network request failed')); + vi.stubGlobal('fetch', fetchMock); }); afterEach(() => { - netinfo.listeners.clear(); + act(() => { + for (const renderer of renderers.splice(0)) { + renderer.unmount(); + } + }); + netinfo.reset(); + vi.clearAllTimers(); vi.useRealTimers(); + vi.unstubAllGlobals(); }); - it('subscribes once, shows after the delay, hides at once, and reports the tri-state', async () => { - vi.useFakeTimers(); - - const hooks = await loadHooks(); - const renderer = await renderProbe(hooks); - + it('keeps tri-state boot, confirms transport failure after five seconds, and recovers immediately', async () => { + const renderer = await renderProbe(await loadHooks()); expect(netinfo.listeners.size).toBe(1); expect(textChildren(renderer)).toEqual(['false:unknown']); - act(() => { - netinfo.emit({ isConnected: false, isInternetReachable: false }); + netinfo.emit(offlineState); }); + await advanceBy(4999); expect(textChildren(renderer)).toEqual(['false:unknown']); - + expect(fetchMock).not.toHaveBeenCalled(); + await advanceBy(1); + expect(textChildren(renderer)).toEqual(['true:offline']); act(() => { - vi.advanceTimersByTime(OFFLINE_BANNER_SHOW_DELAY_MS); + netinfo.emit(onlineState); }); - expect(textChildren(renderer)).toEqual(['true:offline']); + expect(textChildren(renderer)).toEqual(['false:online']); + }); + it.each([200, 401, 405, 503])( + 'treats HTTP %s from configured HEAD as reachable', + async status => { + fetchMock.mockResolvedValue(new Response(null, { status })); + netinfo.emit(offlineState); + const renderer = await renderProbe(await loadHooks()); + await advanceBy(5000); + expect(textChildren(renderer)).toEqual(['false:online']); + expect(fetchMock).toHaveBeenCalledExactlyOnceWith('https://configured-api.example.test', { + method: 'HEAD', + signal: expect.any(AbortSignal), + }); + const signal = fetchMock.mock.calls[0]?.[1]?.signal; + await advanceBy(4000); + expect(signal?.aborted).toBe(false); + expect(textChildren(renderer)).toEqual(['false:online']); + expect(fetchMock).toHaveBeenCalledTimes(1); + } + ); + + it.each(['cold open', 'resume'])( + 'keeps false NetInfo hidden for nine seconds on %s', + async sequence => { + fetchMock.mockResolvedValue(new Response(null, { status: 204 })); + netinfo.emit(sequence === 'cold open' ? offlineState : onlineState); + const renderer = await renderProbe(await loadHooks()); + if (sequence === 'resume') { + expect(textChildren(renderer)).toEqual(['false:online']); + act(() => { + netinfo.emit(offlineState); + }); + } + await advanceBy(4999); + expect(textChildren(renderer)?.[0]).toMatch(/^false:/); + expect(fetchMock).not.toHaveBeenCalled(); + await advanceBy(4001); + expect(textChildren(renderer)).toEqual(['false:online']); + expect(fetchMock).toHaveBeenCalledTimes(1); + } + ); + + it('aborts exactly three seconds after probe start and ignores a late HTTP response', async () => { + const response = Promise.withResolvers(); + fetchMock.mockReturnValue(response.promise); + netinfo.emit(offlineState); + const renderer = await renderProbe(await loadHooks()); + await advanceBy(5000); + const signal = fetchMock.mock.calls[0]?.[1]?.signal; + expect(signal).toBeInstanceOf(AbortSignal); + await advanceBy(2999); + expect(signal?.aborted).toBe(false); + expect(textChildren(renderer)).toEqual(['false:unknown']); + await advanceBy(1); + expect(signal?.aborted).toBe(true); + expect(textChildren(renderer)).toEqual(['true:offline']); + await act(async () => { + response.resolve(new Response(null, { status: 200 })); + await Promise.resolve(); + }); + expect(textChildren(renderer)).toEqual(['true:offline']); act(() => { - netinfo.emit({ isConnected: true, isInternetReachable: true }); + netinfo.emit(onlineState); }); expect(textChildren(renderer)).toEqual(['false:online']); }); - it('reports the unknown → online edge even though the banner stays hidden', async () => { - vi.useFakeTimers(); - - const hooks = await loadHooks(); - const renderer = await renderProbe(hooks); - + it('ignores a deadline after NetInfo recovers during confirmation', async () => { + fetchMock.mockReturnValue(new Promise(() => undefined)); + netinfo.emit(offlineState); + const renderer = await renderProbe(await loadHooks()); + await advanceBy(5000); expect(textChildren(renderer)).toEqual(['false:unknown']); - act(() => { - netinfo.emit({ isConnected: true, isInternetReachable: true }); + netinfo.emit(onlineState); }); expect(textChildren(renderer)).toEqual(['false:online']); + await advanceBy(3000); + expect(textChildren(renderer)).toEqual(['false:online']); }); - it('shares one store and one NetInfo subscription across callers', async () => { - vi.useFakeTimers(); + it('reports unknown to online even though the banner stays hidden', async () => { + const renderer = await renderProbe(await loadHooks()); + expect(textChildren(renderer)).toEqual(['false:unknown']); + act(() => { + netinfo.emit(onlineState); + }); + expect(textChildren(renderer)).toEqual(['false:online']); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('shares one confirmation and one NetInfo subscription across callers', async () => { const hooks = await loadHooks(); const first = await renderProbe(hooks); const second = await renderProbe(hooks); - expect(netinfo.listeners.size).toBe(1); - - act(() => { - netinfo.emit({ isConnected: false, isInternetReachable: false }); - }); act(() => { - vi.advanceTimersByTime(OFFLINE_BANNER_SHOW_DELAY_MS); + netinfo.emit(offlineState); }); - + await advanceBy(5000); expect(textChildren(first)).toEqual(['true:offline']); expect(textChildren(second)).toEqual(['true:offline']); + expect(fetchMock).toHaveBeenCalledTimes(1); }); - it('keeps the shared store alive after unmount (never destroyed)', async () => { - vi.useFakeTimers(); - + it('keeps the singleton processing connectivity after its consumers unmount', async () => { const hooks = await loadHooks(); const renderer = await renderProbe(hooks); - - expect(netinfo.listeners.size).toBe(1); - act(() => { renderer.unmount(); }); - + act(() => { + netinfo.emit(offlineState); + }); + await advanceBy(5000); + const remounted = await renderProbe(hooks); + expect(textChildren(remounted)).toEqual(['true:offline']); expect(netinfo.listeners.size).toBe(1); + expect(fetchMock).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/mobile/src/lib/hooks/use-offline-banner-state.ts b/apps/mobile/src/lib/hooks/use-offline-banner-state.ts index d83a9626ee..a9399a4cd2 100644 --- a/apps/mobile/src/lib/hooks/use-offline-banner-state.ts +++ b/apps/mobile/src/lib/hooks/use-offline-banner-state.ts @@ -1,6 +1,8 @@ +import { withDeadline } from '@kilocode/event-service'; import { addEventListener } from '@react-native-community/netinfo'; import { useSyncExternalStore } from 'react'; +import { API_BASE_URL } from '@/lib/config'; import { type BannerState, type ConnectivitySource, @@ -30,7 +32,17 @@ const defaultTimer: OfflineBannerTimer = { let store: OfflineBannerStore | null = null; function getStore(): OfflineBannerStore { - store ??= createOfflineBannerStore({ source: netInfoSource, timer: defaultTimer }); + store ??= createOfflineBannerStore({ + source: netInfoSource, + timer: defaultTimer, + probe: async () => { + await withDeadline(3000, async signal => { + await fetch(API_BASE_URL, { method: 'HEAD', signal }); + }); + // Any HTTP response proves reachability, including application errors. + return true; + }, + }); return store; } diff --git a/apps/mobile/src/lib/offline-banner-state.test.ts b/apps/mobile/src/lib/offline-banner-state.test.ts index 8e11d5f45b..d3cae5cf66 100644 --- a/apps/mobile/src/lib/offline-banner-state.test.ts +++ b/apps/mobile/src/lib/offline-banner-state.test.ts @@ -2,16 +2,17 @@ import { describe, expect, it, vi } from 'vitest'; import { type ConnectivityState } from '@/lib/connectivity-online'; import { + type BannerState, type ConnectivitySource, createOfflineBannerStore, - OFFLINE_BANNER_SHOW_DELAY_MS, - type OfflineBannerStore, type OfflineBannerTimer, } from '@/lib/offline-banner-state'; -const offlineState: ConnectivityState = { isConnected: false, isInternetReachable: false }; +const offlineState: ConnectivityState = { isConnected: true, isInternetReachable: false }; const onlineState: ConnectivityState = { isConnected: true, isInternetReachable: true }; const unknownState: ConnectivityState = { isConnected: null, isInternetReachable: null }; +const outcomes = ['online', 'offline', 'reject'] as const; +type Outcome = (typeof outcomes)[number]; function createFakeSource() { const listeners = new Set<(state: ConnectivityState) => void>(); @@ -36,14 +37,13 @@ function createFakeSource() { }; } -type ScheduledEntry = { callback: () => void; cancelled: boolean; delayMs: number }; - function createFakeTimer() { - const scheduled: ScheduledEntry[] = []; + let now = 0; + const scheduled: { callback: () => void; at: number; cancelled: boolean }[] = []; const timer: OfflineBannerTimer = { - // oxlint-disable-next-line promise/prefer-await-to-callbacks -- the fake timer stores callbacks for manual firing + // oxlint-disable-next-line promise/prefer-await-to-callbacks -- manually controlled timer callbacks set(callback, delayMs) { - const entry: ScheduledEntry = { callback, cancelled: false, delayMs }; + const entry = { callback, at: now + delayMs, cancelled: false }; scheduled.push(entry); return { cancel() { @@ -55,218 +55,242 @@ function createFakeTimer() { return { timer, scheduled, - firePending(): void { - while (scheduled.length > 0) { - const entry = scheduled.shift(); - if (!entry) { - return; - } - if (!entry.cancelled) { + advanceBy(ms: number): void { + now += ms; + for (const entry of scheduled) { + if (!entry.cancelled && entry.at <= now) { + entry.cancelled = true; entry.callback(); - return; } } }, }; } -function createStore( - source = createFakeSource(), - timer = createFakeTimer() -): { - store: OfflineBannerStore; - source: ReturnType; - timer: ReturnType; -} { +function createStore() { + const source = createFakeSource(); + const timer = createFakeTimer(); + const attempts: ReturnType>[] = []; + const probe = vi.fn(async () => { + const attempt = Promise.withResolvers(); + attempts.push(attempt); + const result = await attempt.promise; + return result; + }); + const store = createOfflineBannerStore({ source: source.source, timer: timer.timer, probe }); + const changes: BannerState[] = []; + store.subscribe(() => { + changes.push(store.state()); + }); return { - store: createOfflineBannerStore({ source: source.source, timer: timer.timer }), + store, source, timer, + probe, + changes, + settle: async (index: number, outcome: Outcome) => { + const attempt = attempts[index]; + if (!attempt) { + throw new Error(`Missing probe ${index}`); + } + if (outcome === 'reject') { + attempt.reject(new Error('Transport failed')); + } else { + attempt.resolve(outcome === 'online'); + } + await Promise.allSettled([attempt.promise]); + }, }; } describe('createOfflineBannerStore', () => { - it('starts unknown, not offline', () => { - const { store } = createStore(); - - expect(store.isOffline()).toBe(false); + it('starts unknown and stays hidden without probing unknown connectivity', () => { + const { store, source, timer, probe, changes } = createStore(); expect(store.state()).toBe('unknown'); - }); - - it('does not show the offline banner while connectivity is unknown', () => { - const { store, source, timer } = createStore(); - const listener = vi.fn(() => undefined); - store.subscribe(listener); - source.emit(unknownState); - timer.firePending(); - - expect(store.isOffline()).toBe(false); - expect(listener).not.toHaveBeenCalled(); - }); - - it('notifies on unknown → online (the banner stays hidden)', () => { - const { store, source } = createStore(); - const listener = vi.fn(() => undefined); - store.subscribe(listener); - - source.emit(unknownState); - source.emit(onlineState); - + timer.advanceBy(10_000); + expect(store.state()).toBe('unknown'); expect(store.isOffline()).toBe(false); - expect(store.state()).toBe('online'); - expect(listener).toHaveBeenCalledTimes(1); + expect(changes).toEqual([]); + expect(probe).not.toHaveBeenCalled(); }); - it('commits offline only after the show delay and notifies once', () => { - const { store, source, timer } = createStore(); - const listener = vi.fn(() => undefined); - store.subscribe(listener); - + it.each(outcomes)('waits exactly five seconds and one current probe before %s', async outcome => { + const { store, source, timer, probe, changes, settle } = createStore(); source.emit(offlineState); - + timer.advanceBy(4999); + expect(store.state()).toBe('unknown'); + expect(probe).not.toHaveBeenCalled(); + timer.advanceBy(1); + expect(probe).toHaveBeenCalledTimes(1); expect(store.isOffline()).toBe(false); - expect(listener).not.toHaveBeenCalled(); - - expect(timer.scheduled[0]?.delayMs).toBe(OFFLINE_BANNER_SHOW_DELAY_MS); - - timer.firePending(); + expect(changes).toEqual([]); + timer.advanceBy(10_000); + expect(probe).toHaveBeenCalledTimes(1); + await settle(0, outcome); + const expected = outcome === 'online' ? 'online' : 'offline'; + expect(store.state()).toBe(expected); + expect(store.isOffline()).toBe(expected === 'offline'); + expect(changes).toEqual([expected]); + }); - expect(store.isOffline()).toBe(true); - expect(listener).toHaveBeenCalledTimes(1); + it('handles a synchronous probe throw as a failed confirmation', () => { + const { store, source, timer, probe, changes } = createStore(); + probe.mockImplementationOnce(() => { + throw new Error('Probe could not start'); + }); + source.emit({ isConnected: false, isInternetReachable: false }); + timer.advanceBy(5000); + expect(store.state()).toBe('offline'); + expect(changes).toEqual(['offline']); }); - it('cancels the pending offline commit when the state returns online inside the window', () => { - const { store, source, timer } = createStore(); - const listener = vi.fn(() => undefined); - store.subscribe(listener); + it.each([ + { event: onlineState, expected: 'online', changes: ['online'] }, + { event: unknownState, expected: 'unknown', changes: [] }, + ])('cancels the delay on $expected without probing', ({ event, expected, changes }) => { + const fixture = createStore(); + fixture.source.emit(offlineState); + fixture.source.emit(event); + fixture.timer.advanceBy(5000); + expect(fixture.store.state()).toBe(expected); + expect(fixture.store.isOffline()).toBe(false); + expect(fixture.changes).toEqual(changes); + expect(fixture.probe).not.toHaveBeenCalled(); + }); + it('restarts the full delay on repeated offline reports and ignores a cancelled callback', async () => { + const { store, source, timer, probe, settle } = createStore(); source.emit(offlineState); - source.emit(onlineState); - - timer.firePending(); - - expect(store.isOffline()).toBe(false); - expect(store.state()).toBe('online'); - expect(listener).toHaveBeenCalledTimes(1); + timer.advanceBy(4999); + source.emit(offlineState); + timer.scheduled[0]?.callback(); + timer.advanceBy(1); + expect(store.state()).toBe('unknown'); + expect(probe).not.toHaveBeenCalled(); + timer.advanceBy(4999); + await settle(0, 'offline'); + expect(store.isOffline()).toBe(true); + expect(probe).toHaveBeenCalledTimes(1); }); - it('hides immediately when the connection returns after a committed offline', () => { - const { store, source, timer } = createStore(); - const listener = vi.fn(() => undefined); - store.subscribe(listener); - + it.each(outcomes)('hides immediately on recovery and ignores the in-flight %s', async outcome => { + const { store, source, timer, changes, settle } = createStore(); source.emit(offlineState); - timer.firePending(); + timer.advanceBy(5000); + await settle(0, 'offline'); expect(store.isOffline()).toBe(true); - expect(listener).toHaveBeenCalledTimes(1); - + source.emit(offlineState); + timer.advanceBy(5000); source.emit(onlineState); - + expect(store.state()).toBe('online'); expect(store.isOffline()).toBe(false); - expect(listener).toHaveBeenCalledTimes(2); - expect(timer.scheduled.filter(entry => !entry.cancelled)).toEqual([]); + await settle(1, outcome); + expect(store.state()).toBe('online'); + expect(changes).toEqual(['offline', 'online']); }); - it('commits exactly once for rapid alternation, matching the final quiet state', () => { - const { store, source, timer } = createStore(); - const listener = vi.fn(() => undefined); - store.subscribe(listener); - + it.each(outcomes)( + 'invalidates an in-flight %s on unknown without advancing boot state', + async outcome => { + const { store, source, timer, changes, settle } = createStore(); + source.emit(offlineState); + timer.advanceBy(5000); + source.emit(unknownState); + await settle(0, outcome); + expect(store.state()).toBe('unknown'); + expect(store.isOffline()).toBe(false); + expect(changes).toEqual([]); + } + ); + + it.each(outcomes)( + 'invalidates an in-flight %s while a newer offline delay is pending', + async outcome => { + const { store, source, timer, changes, settle } = createStore(); + source.emit(offlineState); + timer.advanceBy(5000); + source.emit(offlineState); + await settle(0, outcome); + expect(store.state()).toBe('unknown'); + expect(changes).toEqual([]); + timer.advanceBy(5000); + await settle(1, 'offline'); + expect(store.state()).toBe('offline'); + expect(changes).toEqual(['offline']); + } + ); + + it.each([ + ['offline', 'online'], + ['reject', 'online'], + ['online', 'offline'], + ] as const)('ignores older %s after a newer %s result', async (older, newer) => { + const { store, source, timer, probe, changes, settle } = createStore(); source.emit(offlineState); - source.emit(onlineState); - source.emit(offlineState); - source.emit(onlineState); + timer.advanceBy(5000); + source.emit(unknownState); source.emit(offlineState); - - timer.firePending(); - - expect(store.isOffline()).toBe(true); - // One notification for the unknown → online commit, one for the final - // offline commit — the intermediate online commits are no-ops. - expect(listener).toHaveBeenCalledTimes(2); + timer.advanceBy(5000); + await settle(1, newer); + expect(store.state()).toBe(newer); + await settle(0, older); + expect(store.state()).toBe(newer); + expect(changes).toEqual([newer]); + expect(probe).toHaveBeenCalledTimes(2); }); - it('flapping unknown → offline → online → offline shows only after the debounce and hides immediately on online', () => { - const { store, source, timer } = createStore(); - const listener = vi.fn(() => undefined); - store.subscribe(listener); - - source.emit(unknownState); - timer.firePending(); - expect(store.isOffline()).toBe(false); - expect(listener).not.toHaveBeenCalled(); - + it('preserves confirmed offline on unknown and avoids duplicate offline notifications', async () => { + const { store, source, timer, changes, settle } = createStore(); source.emit(offlineState); - // Not committed yet: the banner waits out the show delay. - expect(store.isOffline()).toBe(false); - expect(timer.scheduled[0]?.delayMs).toBe(OFFLINE_BANNER_SHOW_DELAY_MS); - - source.emit(onlineState); - // Hides immediately: the pending offline commit was cancelled. - expect(store.isOffline()).toBe(false); - expect(store.state()).toBe('online'); - + timer.advanceBy(5000); + await settle(0, 'offline'); source.emit(offlineState); - timer.firePending(); + timer.advanceBy(5000); + await settle(1, 'offline'); + source.emit(unknownState); expect(store.isOffline()).toBe(true); - expect(store.state()).toBe('offline'); - - // One notification for the unknown → online commit, one for the final - // offline commit. - expect(listener).toHaveBeenCalledTimes(2); + expect(changes).toEqual(['offline']); }); - it('destroy with a pending commit cancels the timer and unsubscribes the source', () => { - const { store, source, timer } = createStore(); - const listener = vi.fn(() => undefined); - store.subscribe(listener); - + it('destroy cancels the timer, unsubscribes, and ignores a queued timer callback', () => { + const { store, source, timer, probe, changes } = createStore(); source.emit(offlineState); store.destroy(); - - timer.firePending(); + timer.scheduled[0]?.callback(); + timer.advanceBy(5000); source.emit(onlineState); - - expect(store.isOffline()).toBe(false); - expect(listener).not.toHaveBeenCalled(); - expect(source.unsubscribe).toHaveBeenCalledTimes(1); - }); - - it('does not notify after destroy when the source emits', () => { - const { store, source, timer } = createStore(); - const listener = vi.fn(() => undefined); - store.subscribe(listener); - - store.destroy(); - source.emit(offlineState); - timer.firePending(); - - expect(listener).not.toHaveBeenCalled(); + expect(store.state()).toBe('unknown'); + expect(changes).toEqual([]); + expect(probe).not.toHaveBeenCalled(); expect(source.unsubscribe).toHaveBeenCalledTimes(1); }); - it('unsubscribes a removed listener', () => { - const { store, source } = createStore(); - const listener = vi.fn(() => undefined); + it.each(outcomes)( + 'ignores %s after destruction without notifying or changing state', + async outcome => { + const { store, source, timer, changes, settle } = createStore(); + source.emit(offlineState); + timer.advanceBy(5000); + store.destroy(); + await settle(0, outcome); + source.emit(onlineState); + expect(store.state()).toBe('unknown'); + expect(store.isOffline()).toBe(false); + expect(changes).toEqual([]); + } + ); + + it('notifies unknown to online once and removes unsubscribed listeners', () => { + const { store, source, changes } = createStore(); + const listener = vi.fn<() => void>(); const remove = store.subscribe(listener); - remove(); - source.emit(offlineState); - - expect(listener).not.toHaveBeenCalled(); - }); - - it('exposes the committed state via state()', () => { - const { store, source, timer } = createStore(); - - expect(store.state()).toBe('unknown'); - + source.emit(onlineState); source.emit(onlineState); expect(store.state()).toBe('online'); - - source.emit(offlineState); - timer.firePending(); - expect(store.state()).toBe('offline'); + expect(store.isOffline()).toBe(false); + expect(changes).toEqual(['online']); + expect(listener).not.toHaveBeenCalled(); }); }); diff --git a/apps/mobile/src/lib/offline-banner-state.ts b/apps/mobile/src/lib/offline-banner-state.ts index 2ec57ee773..43109bf1e8 100644 --- a/apps/mobile/src/lib/offline-banner-state.ts +++ b/apps/mobile/src/lib/offline-banner-state.ts @@ -1,11 +1,6 @@ import { type ConnectivityState, connectivityStatus } from '@/lib/connectivity-online'; -/** - * How long the connection must stay down before the banner appears. NetInfo - * reports a false `offline` for a moment after a long background, so a short - * window flashed the banner on every foreground. Hiding stays immediate: a - * banner that is up when the connection works is the worse error. - */ +/** Wait out transient NetInfo reports before confirming offline with a probe. */ export const OFFLINE_BANNER_SHOW_DELAY_MS = 5000; export type OfflineBannerTimer = { @@ -29,15 +24,17 @@ export type BannerState = 'online' | 'offline' | 'unknown'; export function createOfflineBannerStore(options: { source: ConnectivitySource; timer: OfflineBannerTimer; + probe: () => Promise; showDelayMs?: number; }): OfflineBannerStore { - const { source, timer } = options; + const { source, timer, probe } = options; const showDelayMs = options.showDelayMs ?? OFFLINE_BANNER_SHOW_DELAY_MS; - // Start unknown, not online: until NetInfo settles we cannot claim the - // connection works, but we also must not show the offline banner. + // Start unknown: neither NetInfo nor a probe has confirmed connectivity yet. let state: BannerState = 'unknown'; let pending: { cancel(): void } | null = null; + let generation = 0; + let destroyed = false; const listeners = new Set<() => void>(); function cancelPending(): void { @@ -58,12 +55,28 @@ export function createOfflineBannerStore(options: { } } + async function confirmConnectivity(attempt: number): Promise { + let reachable = false; + try { + reachable = await probe(); + } catch { + // Synchronous throws and rejected probes both permit offline confirmation. + } + if (!destroyed && attempt === generation) { + commit(reachable ? 'online' : 'offline'); + } + } + function handleSourceState(sourceState: ConnectivityState): void { - const status = connectivityStatus(sourceState); + generation += 1; + const attempt = generation; cancelPending(); + if (destroyed) { + return; + } + const status = connectivityStatus(sourceState); if (status === 'unknown') { - // Do not reveal the banner while connectivity is unknown, and do not - // advance the committed state from its boot default. + // Unknown cancels confirmation but preserves the last committed state. return; } if (status === 'online') { @@ -71,8 +84,11 @@ export function createOfflineBannerStore(options: { return; } pending = timer.set(() => { + if (destroyed || attempt !== generation) { + return; + } pending = null; - commit('offline'); + void confirmConnectivity(attempt); }, showDelayMs); } @@ -92,6 +108,8 @@ export function createOfflineBannerStore(options: { const getState = (): BannerState => state; const destroy = (): void => { + destroyed = true; + generation += 1; cancelPending(); unsubscribeSource(); listeners.clear(); From 43537405853ae415516969e867f1efc23ee63f6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 29 Aug 2026 00:00:49 +0200 Subject: [PATCH 2/6] fix(mobile): expose live status throughout session history --- .../session-history-screen.mounted.test.tsx | 100 ++++-- .../agents/session-history-screen.tsx | 2 + .../session-list-content.mounted.test.tsx | 337 ++++++++++++++++++ .../agents/session-list-content.tsx | 6 +- .../session-row-accessibility-label.test.ts | 41 +++ .../agents/session-row-accessibility-label.ts | 9 +- .../agents/session-row.mounted.test.tsx | 265 ++++++++++++++ .../src/components/agents/session-row.tsx | 5 +- .../agents/use-agent-session-list-data.ts | 2 + 9 files changed, 739 insertions(+), 28 deletions(-) create mode 100644 apps/mobile/src/components/agents/session-list-content.mounted.test.tsx create mode 100644 apps/mobile/src/components/agents/session-row.mounted.test.tsx diff --git a/apps/mobile/src/components/agents/session-history-screen.mounted.test.tsx b/apps/mobile/src/components/agents/session-history-screen.mounted.test.tsx index fc12147d90..2355ce4822 100644 --- a/apps/mobile/src/components/agents/session-history-screen.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-history-screen.mounted.test.tsx @@ -3,7 +3,10 @@ import { createElement, type ReactElement } from 'react'; import TestRenderer, { act } from 'react-test-renderer'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { type QueryClient, QueryClientProvider } from '@tanstack/react-query'; + import { i18n } from '@/i18n'; +import { createTestQueryClient, waitFor } from '@/test/render-with-providers'; import { SessionHistoryScreen } from './session-history-screen'; const listState = vi.hoisted(() => ({ @@ -68,7 +71,7 @@ vi.mock('@/components/screen-header', () => ({ })); vi.mock('@/components/agents/use-session-search-input', () => ({ useSessionSearchInput: () => ({ - searchQuery: '', + searchQuery: listState.isSearching ? 'title' : '', searchInputRef: { current: null }, hasText: false, awaitingCommit: false, @@ -83,30 +86,45 @@ vi.mock('@/components/agents/use-session-search-input', () => ({ vi.mock('@/components/agents/use-agent-session-navigator', () => ({ useAgentSessionNavigator: () => vi.fn(), })); -vi.mock('@/components/agents/use-agent-session-list-data', () => ({ - useAgentSessionListData: () => ({ - storedSessions: listState.storedSessions, - activeSessions: [], - isLoading: false, - storedIsFetching: false, - storedLoadedPageCount: 1, - paging: { +vi.mock('@/lib/hooks/use-agent-sessions', async () => { + const { useQuery } = await import('@tanstack/react-query'); + return { + useAgentSessions: () => { + // This dependency owns the existing live query subscription. Keep the + // data hook real so the test covers the complete set handoff. + const active = useQuery({ + queryKey: ['existing-active-sessions'], + queryFn: () => new Set(), + initialData: () => new Set(), + }); + return { + storedSessions: listState.storedSessions, + activeSessionIds: active.data, + dateGroups: [], + activeIsError: false, + storedIsError: false, + storedIsFetching: false, + storedLoadedPageCount: 1, + hasNextPage: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + refetch: handleRefetchSpy, + }; + }, + useAgentSessionSearch: () => ({ + dateGroups: [], + isFetching: false, + isPending: false, + isError: false, hasNextPage: false, isFetchingNextPage: false, isPlaceholderData: false, fetchNextPage: vi.fn(), - }, - refetch: vi.fn(), - handleRetry: vi.fn(), - handleRefetch: handleRefetchSpy, - isSearching: listState.isSearching, - search: { isFetching: false, isPending: false }, - projectOptions: [], - contentIsError: false, - pinnedActive: [], - sections: [], - }), -})); + refetch: vi.fn(), + }), + useRecentAgentRepositories: () => ({ data: { repositories: [] } }), + }; +}); vi.mock('@/lib/hooks/use-persisted-agent-session-filters', () => ({ usePersistedAgentSessionFilters: () => ({ platformFilter: [], @@ -153,13 +171,21 @@ function fireFocus(): void { } } -async function renderScreen(): Promise { +async function renderScreen( + queryClient: QueryClient = createTestQueryClient() +): Promise { const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined, }; await act(async () => { await Promise.resolve(); - rendererRef.current = TestRenderer.create(createElement(SessionHistoryScreen)); + rendererRef.current = TestRenderer.create( + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(SessionHistoryScreen) + ) + ); }); const renderer = rendererRef.current; if (!renderer) { @@ -242,6 +268,34 @@ describe('SessionHistoryScreen', () => { expect(content.props.hasAnySessions).toBe(true); }); + it('hands the existing live query set through data and screen without another subscription', async () => { + listState.storedSessions = [{ session_id: 'stored-1', organization_id: null }]; + const queryClient = createTestQueryClient(); + const renderer = await renderScreen(queryClient); + const content = findNodeByType(renderer, 'AgentSessionListContent'); + const queryKey = ['existing-active-sessions']; + const activeQuery = queryClient.getQueryCache().find({ queryKey }); + + expect(content.props.activeSessionIds).toEqual(new Set()); + expect(activeQuery?.getObserversCount()).toBe(1); + const liveIds = new Set(['stored-1']); + act(() => { + queryClient.setQueryData(queryKey, liveIds); + }); + await waitFor(() => content.props.activeSessionIds === liveIds); + expect(findNodeByType(renderer, 'AgentSessionListContent')).toBe(content); + expect(content.props.activeSessionIds).toBe(liveIds); + + const nextIds = new Set(['stored-2']); + act(() => { + queryClient.setQueryData(queryKey, nextIds); + }); + await waitFor(() => content.props.activeSessionIds === nextIds); + expect(content.props.activeSessionIds).toBe(nextIds); + expect(activeQuery?.getObserversCount()).toBe(1); + expect(queryClient.getQueryCache().getAll()).toHaveLength(1); + }); + it('refetches stored sessions through the wrapped refetch on route focus', async () => { await renderScreen(); diff --git a/apps/mobile/src/components/agents/session-history-screen.tsx b/apps/mobile/src/components/agents/session-history-screen.tsx index 6703dc279d..f1cfb2dbd1 100644 --- a/apps/mobile/src/components/agents/session-history-screen.tsx +++ b/apps/mobile/src/components/agents/session-history-screen.tsx @@ -76,6 +76,7 @@ export function SessionHistoryScreen() { const { storedSessions, + activeSessionIds, storedIsFetching, storedLoadedPageCount, paging, @@ -205,6 +206,7 @@ export function SessionHistoryScreen() { [0]; +type CellProps = { + item: StoredSession; + renderItem: (info: { item: StoredSession }) => ReactElement; +}; +type ListProps = { + sections: SessionSection[]; + renderItem: CellProps['renderItem']; + ListEmptyComponent: ReactNode; + ListFooterComponent: ReactNode; + ref?: Ref<{ getScrollResponder: () => { scrollTo: () => void } }>; + extraData: number; + onEndReached: () => void; +}; +const controls = vi.hoisted(() => ({ + scrollResets: 0, + deleteSession: vi.fn(), + renameSession: vi.fn(), +})); + +vi.mock('react-native', async () => { + const React = await import('react'); + // Virtualized cells reuse their renderer until its identity changes. This + // catches a stale live-set closure even when the list itself re-renders. + const Cell = React.memo(function SessionCell({ item, renderItem }: CellProps) { + return renderItem({ item }); + }); + return { + View: 'View', + ActivityIndicator: 'ActivityIndicator', + RefreshControl: 'RefreshControl', + Platform: { OS: 'ios' }, + useWindowDimensions: () => ({ fontScale: 1 }), + SectionList: ({ + sections, + renderItem, + ListEmptyComponent, + ListFooterComponent, + ref, + ...props + }: ListProps) => { + React.useImperativeHandle( + ref, + () => ({ + getScrollResponder: () => ({ + scrollTo: () => { + controls.scrollResets += 1; + }, + }), + }), + [] + ); + return React.createElement( + 'SectionList', + props, + sections.flatMap(section => + section.data.map(item => + React.createElement(Cell, { key: item.session_id, item, renderItem }) + ) + ), + sections.length === 0 ? ListEmptyComponent : null, + ListFooterComponent + ); + }, + }; +}); +vi.mock('expo-router', async () => { + const { useEffect } = await import('react'); + return { + useScrollToTop: () => undefined, + useFocusEffect: (effect: () => void) => { + useEffect(effect, [effect]); + }, + }; +}); +vi.mock('react-native-reanimated', () => ({ + default: { View: 'AnimatedView' }, + FadeIn: { duration: () => undefined }, + FadeOut: { duration: () => undefined }, +})); +vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) })); +vi.mock('@/components/agents/session-row', () => ({ StoredSessionRow: 'StoredSessionRow' })); +vi.mock('@/components/agents/session-list-section-header', () => ({ + SessionListSectionHeader: 'SessionListSectionHeader', +})); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); +vi.mock('@/components/ui/accessible-status', () => ({ AccessibleStatus: 'AccessibleStatus' })); +vi.mock('@/components/ui/icons', () => ({ + History: 'History', + SearchX: 'SearchX', + AlertCircle: 'AlertCircle', + Lock: 'Lock', + ServerCrash: 'ServerCrash', + WifiOff: 'WifiOff', +})); +vi.mock('@/lib/a11y/announce', () => ({ moveA11yFocus: vi.fn() })); +vi.mock('@/lib/hooks/use-session-mutations', () => ({ useSessionMutations: () => controls })); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ mutedForeground: '#999999' }), +})); +vi.mock('@/lib/session-attention', () => ({ getRevisionSnapshot: () => 17 })); + +function session(id: string): StoredSession { + return { + session_id: id, + title: `${id} title`, + organization_id: 'org-1', + cloud_agent_session_id: null, + parent_session_id: null, + created_on_platform: 'cli', + git_url: null, + git_branch: null, + status: null, + status_updated_at: null, + total_cost_microdollars: null, + created_at: '2026-08-28T10:00:00.000Z', + updated_at: '2026-08-28T11:55:00.000Z', + version: 0, + associatedPr: null, + }; +} + +type ContentProps = Parameters[0]; +function contentProps(overrides: Partial = {}): ContentProps { + return { + searchInputRef: { current: null }, + sections: [], + activeSessionIds: new Set(), + hasAnySessions: true, + isLoading: false, + isError: false, + isFetchingNextPage: false, + refetch: vi.fn().mockResolvedValue(undefined), + onRetry: () => undefined, + onEndReached: () => undefined, + onSessionPress: () => undefined, + hasActiveQuery: false, + isSearching: false, + searchQuery: '', + onClearQuery: () => undefined, + sortBy: 'updated_at', + ...overrides, + }; +} +const mounted: TestRenderer.ReactTestRenderer[] = []; +function mount(props: ContentProps): TestRenderer.ReactTestRenderer { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + act(() => { + ref.current = TestRenderer.create(createElement(AgentSessionListContent, props)); + }); + const renderer = ref.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + mounted.push(renderer); + return renderer; +} +function hosts(renderer: TestRenderer.ReactTestRenderer, type: string) { + return renderer.root.findAll(node => node.type === type); +} +function rows(renderer: TestRenderer.ReactTestRenderer) { + return hosts(renderer, 'StoredSessionRow').map(node => { + const { session: stored, live, metaWhileLive } = node.props as RowProps; + return { id: stored.session_id, live, metaWhileLive }; + }); +} +function press(node: TestRenderer.ReactTestInstance | undefined) { + if (!node) { + throw new Error('press target was not rendered'); + } + const { onPress } = node.props as { onPress: () => void }; + act(onPress); +} + +describe('AgentSessionListContent liveness', () => { + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + controls.scrollResets = 0; + vi.clearAllMocks(); + }); + afterEach(() => { + act(() => { + for (const renderer of mounted) { + renderer.unmount(); + } + }); + mounted.length = 0; + }); + + it.each(['normal', 'filtered', 'searched', 'later-page'])( + 'updates %s rows without remounting or resetting scroll', + mode => { + let destination: Parameters | undefined = undefined; + const first = session('first'); + const later = session('later'); + const allSections = [{ title: 'Today', data: [first, later] }]; + let props: ContentProps = contentProps({ + sections: mode === 'later-page' ? [{ title: 'Today', data: [first] }] : allSections, + activeSessionIds: new Set(['first', 'later title', 'active-only']), + hasActiveQuery: mode === 'filtered' || mode === 'searched', + isSearching: mode === 'searched', + searchQuery: mode === 'searched' ? 'title' : '', + onSessionPress: (...args) => { + destination = args; + }, + onEndReached: () => { + props = { ...props, sections: allSections }; + renderer.update(createElement(AgentSessionListContent, props)); + }, + }); + const renderer = mount(props); + const list = renderer.root.find(node => node.type === 'SectionList'); + if (mode === 'later-page') { + expect(rows(renderer)).toHaveLength(1); + const { onEndReached } = list.props as ListProps; + act(onEndReached); + } + expect(rows(renderer)).toEqual([ + { id: 'first', live: true, metaWhileLive: true }, + { id: 'later', live: false, metaWhileLive: true }, + ]); + + act(() => { + renderer.update( + createElement(AgentSessionListContent, { + ...props, + activeSessionIds: new Set(['later', 'active-only']), + }) + ); + }); + expect(rows(renderer)).toEqual([ + { id: 'first', live: false, metaWhileLive: true }, + { id: 'later', live: true, metaWhileLive: true }, + ]); + expect(hosts(renderer, 'SectionList')[0]).toBe(list); + expect(list.props.extraData).toBe(17); + expect(controls.scrollResets).toBe(0); + press(hosts(renderer, 'StoredSessionRow')[1]); + expect(destination).toEqual(['later', 'org-1', 'later title']); + + act(() => { + renderer.update(createElement(AgentSessionListContent, { ...props, sortBy: 'created_at' })); + }); + expect(hosts(renderer, 'SectionList')[0]).not.toBe(list); + expect(rows(renderer).map(item => item.id)).toEqual(['first', 'later']); + } + ); + + it.each([false, true])('preserves retry recovery for searching=%s', isSearching => { + const recovered = contentProps({ + sections: [{ title: 'Today', data: [session('recovered')] }], + }); + const renderer = mount( + contentProps({ + isError: true, + hasAnySessions: isSearching, + hasActiveQuery: isSearching, + isSearching, + searchQuery: isSearching ? 'title' : '', + onRetry: () => { + renderer.update(createElement(AgentSessionListContent, recovered)); + }, + }) + ); + expect(hosts(renderer, 'AccessibleStatus').map(node => node.props.message)).toContain( + i18n.t(isSearching ? 'agents.sessionList.couldNotSearch' : 'agents.sessionList.couldNotLoad') + ); + const retry = renderer.root.find( + node => node.type === 'Button' && node.props.accessibilityLabel === 'Retry' + ); + press(retry); + expect(rows(renderer).map(item => item.id)).toEqual(['recovered']); + }); + + it.each([false, true])('keeps the clear control for empty searching=%s', isSearching => { + const recovered = contentProps({ + sections: [{ title: 'Today', data: [session('recovered')] }], + }); + const renderer = mount( + contentProps({ + hasActiveQuery: true, + isSearching, + searchQuery: isSearching ? 'missing' : '', + onClearQuery: () => { + renderer.update(createElement(AgentSessionListContent, recovered)); + }, + }) + ); + const texts = hosts(renderer, 'Text').map(node => node.props.children); + expect(texts).toContain(i18n.t('agents.sessionList.noMatches')); + expect(texts).toContain(isSearching ? 'Clear search' : 'Clear filters'); + press(hosts(renderer, 'Button')[0]); + expect(rows(renderer).map(item => item.id)).toEqual(['recovered']); + }); + + it('keeps empty history without a creation action even when active IDs exist', () => { + const renderer = mount( + contentProps({ hasAnySessions: false, activeSessionIds: new Set(['active-only']) }) + ); + expect(hosts(renderer, 'Text').map(node => node.props.children)).toContain('No past sessions'); + expect(hosts(renderer, 'Button')).toHaveLength(0); + expect(hosts(renderer, 'SectionList')).toHaveLength(0); + expect(rows(renderer)).toEqual([]); + }); + + it('retains cached live rows after a failed refetch', () => { + const renderer = mount( + contentProps({ + isError: true, + sections: [{ title: 'Today', data: [session('cached')] }], + activeSessionIds: new Set(['cached']), + }) + ); + expect(rows(renderer)).toEqual([{ id: 'cached', live: true, metaWhileLive: true }]); + expect(hosts(renderer, 'AccessibleStatus')).toHaveLength(0); + }); + + it('keeps the loading skeletons instead of flashing empty history', () => { + const renderer = mount(contentProps({ isLoading: true, hasAnySessions: false })); + expect(hosts(renderer, 'Skeleton')).toHaveLength(8); + expect(hosts(renderer, 'Button')).toHaveLength(0); + expect(rows(renderer)).toEqual([]); + }); +}); diff --git a/apps/mobile/src/components/agents/session-list-content.tsx b/apps/mobile/src/components/agents/session-list-content.tsx index e0d628a345..d482870d96 100644 --- a/apps/mobile/src/components/agents/session-list-content.tsx +++ b/apps/mobile/src/components/agents/session-list-content.tsx @@ -39,6 +39,7 @@ type AgentSessionListContentProps = { /** Post-deletion focus anchor: the screen's always-mounted search input. */ searchInputRef: Parameters[0]; sections: SessionSection[]; + activeSessionIds: ReadonlySet; hasAnySessions: boolean; isLoading: boolean; /** Body-driving error flag — a search failure (when searching) OR a @@ -62,6 +63,7 @@ type AgentSessionListContentProps = { export function AgentSessionListContent({ searchInputRef, sections, + activeSessionIds, hasAnySessions, isLoading, isError, @@ -170,6 +172,8 @@ export function AgentSessionListContent({ { onSessionPress(item.session_id, item.organization_id, item.title ?? undefined); }} @@ -186,7 +190,7 @@ export function AgentSessionListContent({ }} /> ), - [onSessionPress, deleteSession, renameSession, sortBy, searchInputRef] + [activeSessionIds, onSessionPress, deleteSession, renameSession, sortBy, searchInputRef] ); const renderSectionHeader = useCallback( diff --git a/apps/mobile/src/components/agents/session-row-accessibility-label.test.ts b/apps/mobile/src/components/agents/session-row-accessibility-label.test.ts index 69b424180a..eec4a8ec53 100644 --- a/apps/mobile/src/components/agents/session-row-accessibility-label.test.ts +++ b/apps/mobile/src/components/agents/session-row-accessibility-label.test.ts @@ -94,6 +94,47 @@ describe('formatSpokenTimeAgo', () => { }); describe('sessionRowAccessibilityLabel', () => { + it.each([undefined, false])('preserves nonlive speech for live=%s', live => { + expect( + sessionRowAccessibilityLabel({ + title: 'Fix login bug', + needsInput: false, + live, + badge: 'CLI', + meta: '5 minutes ago', + }) + ).toBe('Fix login bug, CLI, and 5 minutes ago'); + }); + + it('speaks live status without dropping provenance, metadata, or platform', () => { + expect( + sessionRowAccessibilityLabel({ + title: 'Fix login bug', + needsInput: false, + live: true, + badge: 'CLOUD', + subtitle: 'feature/x', + prNumber: 7, + meta: '5 minutes ago', + platform: 'cloud-agent', + }) + ).toBe( + 'Fix login bug, LIVE, feature/x, pull request 7, CLOUD, 5 minutes ago, and from CLOUD AGENT' + ); + }); + + it('speaks needs input instead of live when attention takes priority', () => { + expect( + sessionRowAccessibilityLabel({ + title: 'Fix login bug', + needsInput: true, + live: true, + badge: 'CLI', + meta: null, + }) + ).toBe('Fix login bug, needs input, and CLI'); + }); + describe('needs-input variant — StoredSessionRow (meta omitted)', () => { it('produces "title, needs input, badge" with meta=null', () => { // Stored row, needs-input eyebrow wins: meta is NOT rendered. diff --git a/apps/mobile/src/components/agents/session-row-accessibility-label.ts b/apps/mobile/src/components/agents/session-row-accessibility-label.ts index 46729af5c3..a5919aed88 100644 --- a/apps/mobile/src/components/agents/session-row-accessibility-label.ts +++ b/apps/mobile/src/components/agents/session-row-accessibility-label.ts @@ -86,6 +86,8 @@ type SessionRowAccessibilityLabelInputs = { title: string; /** True when the row's right eyebrow renders the `NEEDS INPUT` state. */ needsInput: boolean; + /** Opt-in live status for stored list rows; needs-input speech takes priority. */ + live?: boolean; /** * Left-eyebrow badge text, always visible (e.g. "CLI", "VSCODE", "LIVE", * "CLOUD AGENT"). Pass an empty string only as a defensive fallback — @@ -123,13 +125,13 @@ type SessionRowAccessibilityLabelInputs = { /** * Compose the screen-reader label for a `SessionRow`, mirroring its visible * content in the order the row renders parts: title, then `needs input` - * (only when the needs-input eyebrow is shown), then the branch subtitle + * (or localized live status when opted in), then the branch subtitle * (when present), then the `pull request ` phrase (when `prNumber` * is set), then the always-visible left-eyebrow badge, then the meta text * (only when the row visibly renders meta), then an optional platform origin * (`from