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 2bdd917998..30bd924dcb 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 @@ -1,10 +1,13 @@ /* eslint-disable max-lines, typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest; max-lines holds the focus and foreground refetch tests beside the existing render-branch assertions in one mount test. */ import { createElement, type ReactElement } from 'react'; -import TestRenderer, { act } from 'react-test-renderer'; +import { act, type default as TestRenderer } 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 { type StoredSession, type useAgentSessions } from '@/lib/hooks/use-agent-sessions'; +import { createTestQueryClient, renderWithProviders, waitFor } from '@/test/render-with-providers'; import type * as PlatformFilterModule from './platform-filter-modal'; import { SessionHistoryScreen } from './session-history-screen'; @@ -101,50 +104,57 @@ vi.mock('@/components/agents/use-session-search-input', () => ({ vi.mock('@/components/agents/use-agent-session-navigator', () => ({ useAgentSessionNavigator: () => vi.fn(), })); -// Keep query construction and persisted filters real. Only the server response -// is modeled here, so dropped query dimensions change the resulting rows. -vi.mock('@/lib/hooks/use-agent-sessions', () => ({ - useAgentSessions: ({ - gitUrl, - createdOnPlatform, - }: Parameters[0] = {}) => { - const storedSessions = listState.storedSessions.filter( - session => - (!gitUrl || gitUrl.includes(session.git_url ?? '')) && - (!createdOnPlatform || createdOnPlatform.includes(session.created_on_platform ?? '')) - ); - return { - storedSessions, - dateGroups: storedSessions.length > 0 ? [{ label: 'Today', sessions: storedSessions }] : [], - activeIsError: false, - storedIsError: listState.isError, - storedIsFetching: false, - storedLoadedPageCount: 1, +vi.mock('@/lib/hooks/use-agent-sessions', async () => { + const { useQuery } = await import('@tanstack/react-query'); + return { + useAgentSessions: ({ + gitUrl, + createdOnPlatform, + }: Parameters[0] = {}) => { + const active = useQuery({ + queryKey: ['existing-active-sessions'], + queryFn: () => new Set(), + initialData: () => new Set(), + }); + const storedSessions = listState.storedSessions.filter( + session => + (!gitUrl || gitUrl.includes(session.git_url ?? '')) && + (!createdOnPlatform || createdOnPlatform.includes(session.created_on_platform ?? '')) + ); + return { + storedSessions, + activeSessionIds: active.data, + dateGroups: storedSessions.length > 0 ? [{ label: 'Today', sessions: storedSessions }] : [], + activeIsError: false, + storedIsError: listState.isError, + storedIsFetching: false, + storedLoadedPageCount: 1, + hasNextPage: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + refetch: handleRefetchSpy, + }; + }, + useAgentSessionSearch: () => ({ + dateGroups: [], + isError: listState.isError, + isFetching: false, + isPending: false, hasNextPage: false, isFetchingNextPage: false, + isPlaceholderData: false, fetchNextPage: vi.fn(), refetch: handleRefetchSpy, - }; - }, - useAgentSessionSearch: () => ({ - dateGroups: [], - isError: listState.isError, - isFetching: false, - isPending: false, - hasNextPage: false, - isFetchingNextPage: false, - isPlaceholderData: false, - fetchNextPage: vi.fn(), - refetch: handleRefetchSpy, - }), - useRecentAgentRepositories: () => ({ - data: { - repositories: listState.storedSessions.flatMap(session => - session.git_url ? [{ gitUrl: session.git_url }] : [] - ), - }, - }), -})); + }), + useRecentAgentRepositories: () => ({ + data: { + repositories: listState.storedSessions.flatMap(session => + session.git_url ? [{ gitUrl: session.git_url }] : [] + ), + }, + }), + }; +}); vi.mock('expo-secure-store', () => ({ getItemAsync: readFilterRecord })); vi.mock('@/lib/auth/account-metadata-write', () => ({ setAccountMetadata: vi.fn().mockResolvedValue(undefined), @@ -217,18 +227,12 @@ function fireFocus(): void { } } -async function renderScreen(): Promise { - const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = { - current: undefined, - }; - await act(async () => { - await Promise.resolve(); - rendererRef.current = TestRenderer.create(createElement(SessionHistoryScreen)); +async function renderScreen( + queryClient: QueryClient = createTestQueryClient() +): Promise { + const { renderer } = await renderWithProviders(createElement(SessionHistoryScreen), { + queryClient, }); - const renderer = rendererRef.current; - if (!renderer) { - throw new Error('renderer was not created'); - } mountedRenderers.push(renderer); return renderer; } @@ -347,7 +351,8 @@ describe('SessionHistoryScreen', () => { : null; }); listState.isError = true; - const renderer = await renderScreen(); + const queryClient = createTestQueryClient(); + const renderer = await renderScreen(queryClient); const content = findNodeByType(renderer, 'AgentSessionListContent'); expect(content.props.isError).toBe(true); expect(historyHeaderActions(renderer).activeFilterCount).toBe(2); @@ -359,7 +364,13 @@ describe('SessionHistoryScreen', () => { await act(async () => { (content.props.onRetry as () => void)(); await Promise.resolve(); - renderer.update(createElement(SessionHistoryScreen)); + renderer.update( + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(SessionHistoryScreen) + ) + ); }); expect(findNodeByType(renderer, 'AgentSessionListContent').props.isError).toBe(false); expect(storedSessionIds(renderer)).toEqual(['code-cloud']); @@ -409,13 +420,20 @@ describe('SessionHistoryScreen', () => { ); listState.storedSessions = sessions; listState.isSearching = true; - const renderer = await renderScreen(); + const queryClient = createTestQueryClient(); + const renderer = await renderScreen(queryClient); const content = findNodeByType(renderer, 'AgentSessionListContent'); expect(content.props.isSearching).toBe(true); expect(storedSessionIds(renderer)).toEqual([]); act(() => { (content.props.onClearQuery as () => void)(); - renderer.update(createElement(SessionHistoryScreen)); + renderer.update( + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(SessionHistoryScreen) + ) + ); }); expect(findNodeByType(renderer, 'AgentSessionListContent').props.isSearching).toBe(false); expect(storedSessionIds(renderer)).toEqual(['code-cloud']); @@ -474,6 +492,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 7dc899ea59..7d212df8a3 100644 --- a/apps/mobile/src/components/agents/session-history-screen.tsx +++ b/apps/mobile/src/components/agents/session-history-screen.tsx @@ -78,6 +78,7 @@ export function SessionHistoryScreen() { const { storedSessions, + activeSessionIds, storedIsFetching, storedLoadedPageCount, paging, @@ -212,6 +213,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, + ...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 isHost(node: TestRenderer.ReactTestInstance, type: string) { + return node.type === type; +} +function hosts(renderer: TestRenderer.ReactTestRenderer, type: string) { + return renderer.root.findAll(node => isHost(node, 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 => isHost(node, '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']); + + expect(hosts(renderer, 'StoredSessionRow').map(node => node.props.sortBy)).toEqual([ + 'created_at', + 'created_at', + ]); + } + ); + + 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 => isHost(node, '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 0ae3476e29..7bff002e85 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 @@ -61,6 +62,7 @@ type AgentSessionListContentProps = { export function AgentSessionListContent({ searchInputRef, sections, + activeSessionIds, hasAnySessions, isLoading, isError, @@ -168,6 +170,8 @@ export function AgentSessionListContent({ { onSessionPress(item.session_id, item.organization_id, item.title ?? undefined); }} @@ -184,7 +188,7 @@ export function AgentSessionListContent({ }} /> ), - [onSessionPress, deleteSession, renameSession, searchInputRef] + [activeSessionIds, onSessionPress, deleteSession, renameSession, 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