From 7cb8d67eac0469034df3a690e987d7a58d940c57 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 04:29:10 -0700 Subject: [PATCH 1/2] Show source scan notices accurately in mobile Agent History Reuse the existing desktop presentation policy in shared code, keep host failures and source notices separate from skipped transcript files, and retain their scanner-authored messages and actionable skip reasons. Validate the presentation fields while preserving newer host agent, kind, and host spellings. Adapted from the remaining mobile presentation in original PR #15671. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- .../MobileAgentSessionHistoryPanel.tsx | 9 +- .../agent-history-reply-schema.test.ts | 23 ++++ .../agent-history-reply-schema.ts | 14 ++- .../src/agent-history/agent-history-styles.ts | 6 +- ...mobile-agent-history-scan-banners.test.tsx | 112 ++++++++++++++++++ .../mobile-agent-history-scan-banners.tsx | 54 +++++++++ .../use-mobile-agent-history-state.ts | 2 +- .../right-sidebar/AiVaultScanIssueBanners.tsx | 2 +- .../ai-vault-scan-issue-state.ts | 50 -------- .../ai-vault-scan-issue-state.test.ts | 28 +++-- src/shared/ai-vault-scan-issue-state.ts | 46 +++++++ 11 files changed, 273 insertions(+), 73 deletions(-) create mode 100644 mobile/src/agent-history/mobile-agent-history-scan-banners.test.tsx create mode 100644 mobile/src/agent-history/mobile-agent-history-scan-banners.tsx delete mode 100644 src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.ts rename src/{renderer/src/components/right-sidebar => shared}/ai-vault-scan-issue-state.test.ts (88%) create mode 100644 src/shared/ai-vault-scan-issue-state.ts diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx index dde5593278e0..7952036371a3 100644 --- a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx +++ b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx @@ -38,6 +38,7 @@ import { useMobileAgentHistoryState } from './use-mobile-agent-history-state' import { buildMobileAgentHistorySections } from './agent-history-sections' import { shouldShowMobileCurrentWorktreeBadge } from './agent-history-current-worktree-badge' import { MobileAgentSessionHistoryList } from './MobileAgentSessionHistoryList' +import { MobileAgentHistoryScanBanners } from './mobile-agent-history-scan-banners' import { resolveMobileAiVaultSessionResumeTarget, type MobileAiVaultResumeFolderWorkspace, @@ -338,13 +339,7 @@ export function MobileAgentSessionHistoryPanel({ autoCorrect={false} /> - {issues.length > 0 ? ( - - - {issues.length} {issues.length === 1 ? 'transcript' : 'transcripts'} skipped - - - ) : null} + {resumeMessage ? ( {resumeMessage} diff --git a/mobile/src/agent-history/agent-history-reply-schema.test.ts b/mobile/src/agent-history/agent-history-reply-schema.test.ts index 35eb9999713d..c58c19625ec1 100644 --- a/mobile/src/agent-history/agent-history-reply-schema.test.ts +++ b/mobile/src/agent-history/agent-history-reply-schema.test.ts @@ -38,6 +38,29 @@ describe('agent history reply schemas', () => { expect(parsed.sessions).toEqual([{ id: 's1', agent: 'some-new-agent' }]) }) + it('preserves newer host notice kinds and optional metadata while checking banner fields', () => { + const issue = { + agent: 'new-agent', + kind: 'new-source-notice', + path: '/remote/source.db', + message: 'Remote source unavailable. Refresh to try again.', + executionHostId: 'new-host:box', + futureMetadata: true + } + expect(agentHistorySessionScanSchema.parse({ sessions: [], issues: [issue] }).issues).toEqual([ + issue + ]) + }) + + it.each([null, {}, { agent: 'opencode', path: '/source.db', message: 12 }])( + 'refuses unreadable banner rows instead of publishing them into ready state: %j', + (issue) => { + expect( + agentHistorySessionScanSchema.safeParse({ sessions: [], issues: [issue] }).success + ).toBe(false) + } + ) + it('requires the resume repo list to be an array', () => { expect(resumeRepoListSchema.safeParse({}).success).toBe(false) expect(resumeRepoListSchema.safeParse({ repos: 'none' }).success).toBe(false) diff --git a/mobile/src/agent-history/agent-history-reply-schema.ts b/mobile/src/agent-history/agent-history-reply-schema.ts index d0feed40ea8e..a05e70723df5 100644 --- a/mobile/src/agent-history/agent-history-reply-schema.ts +++ b/mobile/src/agent-history/agent-history-reply-schema.ts @@ -27,11 +27,11 @@ export const agentHistoryHostStatusSchema = z.looseObject({ * The session scan. * * Both members are required arrays: use-mobile-agent-history-state.ts:133-135 publishes them straight - * into the ready screen state, where the list maps `sessions` and the issue banner counts `issues` + * into the ready screen state, where the list maps `sessions` and the banner presents `issues` * — a reply missing either left the screen `ready` over an undefined container and crashed on the * next render, which is the defect this reader exists to name. * - * The rows stay unknown, and that is deliberate rather than unfinished. A row is an AiVaultSession, + * Session rows stay unknown because their agent vocabulary is echoed on resume. A row is an AiVaultSession, * whose `agent` is a 21-arm vocabulary that grows with every agent CLI Orca learns to scan — and * which this client echoes straight back to the host when it resumes a session. Declaring it would * either refuse a newer host's whole reply or silently drop the very sessions that host added, and @@ -40,7 +40,15 @@ export const agentHistoryHostStatusSchema = z.looseObject({ */ export const agentHistorySessionScanSchema = z.looseObject({ sessions: z.array(z.unknown()), - issues: z.array(z.unknown()) + issues: z.array( + z.looseObject({ + agent: z.string(), + path: z.string(), + message: z.string(), + kind: salvagedOptional('kind', z.string()), + executionHostId: salvagedOptional('executionHostId', z.string()) + }) + ) }) /** diff --git a/mobile/src/agent-history/agent-history-styles.ts b/mobile/src/agent-history/agent-history-styles.ts index c3174623bf40..cec41046f3be 100644 --- a/mobile/src/agent-history/agent-history-styles.ts +++ b/mobile/src/agent-history/agent-history-styles.ts @@ -196,7 +196,11 @@ export const styles = StyleSheet.create({ backgroundColor: colors.bgPanel }, noticeText: { - color: colors.statusAmber, + color: colors.textMuted, + fontSize: typography.metaSize + }, + hostIssueText: { + color: colors.statusRed, fontSize: typography.metaSize }, resumeBanner: { diff --git a/mobile/src/agent-history/mobile-agent-history-scan-banners.test.tsx b/mobile/src/agent-history/mobile-agent-history-scan-banners.test.tsx new file mode 100644 index 000000000000..cf7cf25fb6f0 --- /dev/null +++ b/mobile/src/agent-history/mobile-agent-history-scan-banners.test.tsx @@ -0,0 +1,112 @@ +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { Text } from 'react-native' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AiVaultScanIssue } from '../../../src/shared/ai-vault-types' + +vi.mock('react-native', () => ({ + Text: 'Text', + View: 'View', + StyleSheet: { create: (value: unknown) => value } +})) + +import { MobileAgentHistoryScanBanners } from './mobile-agent-history-scan-banners' +import { styles } from './agent-history-styles' + +let tree: ReactTestRenderer | undefined + +afterEach(() => { + act(() => tree?.unmount()) + tree = undefined +}) + +function render(issues: AiVaultScanIssue[], sessions: { id: string }[] = []) { + let mounted: ReactTestRenderer | undefined + act(() => { + mounted = create() + }) + if (!mounted) { + throw new Error('History banners did not mount') + } + tree = mounted + return mounted.root.findAllByType(Text) +} + +function sourceIssue(): AiVaultScanIssue { + return { + agent: 'opencode', + kind: 'scope', + executionHostId: 'ssh:dev-box', + path: '/home/ada/.local/share/opencode/opencode.db', + message: 'OpenCode history on dev-box is temporarily unavailable. Refresh to try again.' + } +} + +describe('mobile history scan banners', () => { + it('shows source unavailability without claiming that a transcript was skipped', () => { + const issue = sourceIssue() + const texts = render([issue]) + + expect(texts.map((node) => node.children.join(''))).toEqual([issue.message]) + expect(texts[0].props.style).toEqual(styles.noticeText) + }) + + it('counts only failed transcripts while retaining the source notice and actionable reason', () => { + const issue = sourceIssue() + const texts = render( + [ + issue, + { agent: 'claude', path: '/bad.jsonl', message: 'File too large: exceeds 10MB limit' }, + { agent: 'codex', kind: 'notice', path: '', message: 'Additional scan issues omitted.' } + ], + [{ id: 'session' }] + ) + + expect(texts.map((node) => node.children.join(''))).toEqual([ + issue.message, + 'Additional scan issues omitted.', + '1 transcript skipped', + 'File too large: exceeds 10MB limit' + ]) + }) + + it('shows a blocking host error exactly once when there are no sessions', () => { + const host: AiVaultScanIssue = { + agent: 'codex', + executionHostId: 'ssh:dev-box', + kind: 'host', + path: 'dev-box', + message: 'dev-box disconnected. Reconnect the SSH target.' + } + const texts = render([host, sourceIssue()]) + + expect(texts.map((node) => node.children.join(''))).toEqual([ + host.message, + sourceIssue().message + ]) + expect(texts[0].props.style).toEqual(styles.hostIssueText) + }) + + it('keeps distinct host notices visible alongside sessions', () => { + const host: AiVaultScanIssue = { + agent: 'codex', + executionHostId: 'ssh:dev-box', + kind: 'host', + path: 'dev-box', + message: 'dev-box disconnected.' + } + const texts = render( + [host, { ...host, message: 'dev-box scan timed out.' }], + [{ id: 'session' }] + ) + + expect(texts.map((node) => node.children.join(''))).toEqual([ + host.message, + 'dev-box scan timed out.' + ]) + expect(texts.every((node) => node.props.style === styles.hostIssueText)).toBe(true) + }) + + it('renders no banner after a successful refresh', () => { + expect(render([], [{ id: 'session' }])).toEqual([]) + }) +}) diff --git a/mobile/src/agent-history/mobile-agent-history-scan-banners.tsx b/mobile/src/agent-history/mobile-agent-history-scan-banners.tsx new file mode 100644 index 000000000000..9d734823f436 --- /dev/null +++ b/mobile/src/agent-history/mobile-agent-history-scan-banners.tsx @@ -0,0 +1,54 @@ +import { Text, View } from 'react-native' +import type { AiVaultScanIssue, AiVaultSession } from '../../../src/shared/ai-vault-types' +import { + aiVaultScanNoticeIssues, + blockingAiVaultScanIssue, + skippedAiVaultTranscriptCount, + skippedAiVaultTranscriptReasons +} from '../../../src/shared/ai-vault-scan-issue-state' +import { styles } from './agent-history-styles' + +export function MobileAgentHistoryScanBanners({ + sessions, + issues +}: { + sessions: readonly Pick[] + issues: readonly AiVaultScanIssue[] +}) { + const scanResult = { sessions, issues } + const blocking = blockingAiVaultScanIssue(scanResult) + const notices = aiVaultScanNoticeIssues(scanResult) + const skipped = skippedAiVaultTranscriptCount(scanResult) + + return ( + <> + {blocking ? ( + + {blocking.message} + + ) : null} + {notices.map((issue) => ( + + + {issue.message} + + + ))} + {skipped > 0 ? ( + + + {skipped} {skipped === 1 ? 'transcript' : 'transcripts'} skipped + + + ) : null} + {skippedAiVaultTranscriptReasons(scanResult).map((reason) => ( + + {reason} + + ))} + + ) +} diff --git a/mobile/src/agent-history/use-mobile-agent-history-state.ts b/mobile/src/agent-history/use-mobile-agent-history-state.ts index d69262acff65..bffee3de9b85 100644 --- a/mobile/src/agent-history/use-mobile-agent-history-state.ts +++ b/mobile/src/agent-history/use-mobile-agent-history-state.ts @@ -132,7 +132,7 @@ export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams kind: 'ready', // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reader checks both containers; the rows stay the host's own records because `agent` is a vocabulary this client echoes back on resume. aivault-history-screen-listed `normal` records a full row: every member the cards and the resume path read unguarded. sessions: result.sessions as AiVaultSession[], - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: same reader, same container check; the issue rows are the host's AiVaultScanIssue and are only counted, never read member-wise (MobileAgentSessionHistoryPanel.tsx:335). + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reader checks each banner field and preserves unknown agent/kind/host spellings for presentation. issues: result.issues as AiVaultScanIssue[] }) } catch (err) { diff --git a/src/renderer/src/components/right-sidebar/AiVaultScanIssueBanners.tsx b/src/renderer/src/components/right-sidebar/AiVaultScanIssueBanners.tsx index 46171da9cb85..46a6e0ffd1c9 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultScanIssueBanners.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultScanIssueBanners.tsx @@ -5,7 +5,7 @@ import { blockingAiVaultScanIssue, skippedAiVaultTranscriptCount, skippedAiVaultTranscriptReasons -} from './ai-vault-scan-issue-state' +} from '../../../../shared/ai-vault-scan-issue-state' import { translate } from '@/i18n/i18n' // Messages are scanner-authored (host name, remote path, cap), so they render raw diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.ts b/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.ts deleted file mode 100644 index 5bf60d3a17ad..000000000000 --- a/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { AiVaultListResult, AiVaultScanIssue } from '../../../../shared/ai-vault-types' - -export function blockingAiVaultScanIssue( - result: AiVaultListResult | null -): AiVaultScanIssue | null { - if (!result || result.sessions.length > 0) { - return null - } - return result.issues.find((issue) => issue.kind === 'host') ?? null -} - -// Host and scope issues carry their own scanner-authored copy, so they get their -// own rows instead of being counted as skipped transcripts — a partial scan -// (one SSH host down, rest fine) must not report a connectivity failure as a -// skipped transcript file, and an unreadable *source* (a whole locked -// opencode.db holding every OpenCode session) must not read as "1 transcript -// skipped". -export function aiVaultScanNoticeIssues(result: AiVaultListResult | null): AiVaultScanIssue[] { - if (!result) { - return [] - } - const blocking = blockingAiVaultScanIssue(result) - return result.issues.filter((issue) => Boolean(issue.kind) && issue !== blocking) -} - -export function skippedAiVaultTranscriptCount(result: AiVaultListResult | null): number { - return result ? result.issues.filter((issue) => !issue.kind).length : 0 -} - -const SKIPPED_TRANSCRIPT_REASON_LIMIT = 3 - -// Why: a bare "3 transcripts skipped" hides the actionable part (a 10 MiB cap -// hit, an unreadable transcript). Surface the distinct scanner-authored reasons, -// capped so a 500-issue scan can't turn the panel into a wall of text. -export function skippedAiVaultTranscriptReasons(result: AiVaultListResult | null): string[] { - const reasons = new Set() - for (const issue of result?.issues ?? []) { - if (issue.kind) { - continue - } - const message = issue.message.trim() - if (message) { - reasons.add(message) - } - if (reasons.size === SKIPPED_TRANSCRIPT_REASON_LIMIT) { - break - } - } - return [...reasons] -} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.test.ts b/src/shared/ai-vault-scan-issue-state.test.ts similarity index 88% rename from src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.test.ts rename to src/shared/ai-vault-scan-issue-state.test.ts index 80ab908cd1a4..9ee84c633360 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.test.ts +++ b/src/shared/ai-vault-scan-issue-state.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { AiVaultListResult } from '../../../../shared/ai-vault-types' +import type { AiVaultScanIssue } from './ai-vault-types' import { aiVaultScanNoticeIssues, blockingAiVaultScanIssue, @@ -108,6 +108,21 @@ describe('aiVaultScanNoticeIssues', () => { expect(aiVaultScanNoticeIssues(result([], [hostIssue]))).toEqual([]) }) + it('keeps scanner commentary separate from skipped transcripts and their reasons', () => { + const notice: AiVaultScanIssue = { + agent: 'opencode', + kind: 'notice', + path: '', + message: 'Additional scan issues omitted.' + } + const scan = result([], [notice]) + + expect(blockingAiVaultScanIssue(scan)).toBeNull() + expect(aiVaultScanNoticeIssues(scan)).toEqual([notice]) + expect(skippedAiVaultTranscriptCount(scan)).toBe(0) + expect(skippedAiVaultTranscriptReasons(scan)).toEqual([]) + }) + it('reports nothing before the first scan', () => { expect(aiVaultScanNoticeIssues(null)).toEqual([]) expect(skippedAiVaultTranscriptCount(null)).toBe(0) @@ -170,13 +185,6 @@ describe('skippedAiVaultTranscriptReasons', () => { }) }) -function result( - sessions: { id: string }[], - issues: AiVaultListResult['issues'] -): AiVaultListResult { - return { - sessions: sessions as AiVaultListResult['sessions'], - issues, - scannedAt: '2026-07-26T00:00:00.000Z' - } +function result(sessions: { id: string }[], issues: AiVaultScanIssue[]) { + return { sessions, issues } } diff --git a/src/shared/ai-vault-scan-issue-state.ts b/src/shared/ai-vault-scan-issue-state.ts new file mode 100644 index 000000000000..22eed7e4b961 --- /dev/null +++ b/src/shared/ai-vault-scan-issue-state.ts @@ -0,0 +1,46 @@ +import type { AiVaultScanIssue } from './ai-vault-types' + +type AiVaultScanIssueView = { + sessions: readonly { id: string }[] + issues: readonly AiVaultScanIssue[] +} | null + +export function blockingAiVaultScanIssue(result: AiVaultScanIssueView): AiVaultScanIssue | null { + if (!result || result.sessions.length > 0) { + return null + } + return result.issues.find((issue) => issue.kind === 'host') ?? null +} + +// A host or whole source failure is not a skipped transcript file. +export function aiVaultScanNoticeIssues(result: AiVaultScanIssueView): AiVaultScanIssue[] { + if (!result) { + return [] + } + const blocking = blockingAiVaultScanIssue(result) + return result.issues.filter((issue) => Boolean(issue.kind) && issue !== blocking) +} + +export function skippedAiVaultTranscriptCount(result: AiVaultScanIssueView): number { + return result ? result.issues.filter((issue) => !issue.kind).length : 0 +} + +const SKIPPED_TRANSCRIPT_REASON_LIMIT = 3 + +// Keep actionable reasons visible without letting hundreds of issues fill the panel. +export function skippedAiVaultTranscriptReasons(result: AiVaultScanIssueView): string[] { + const reasons = new Set() + for (const issue of result?.issues ?? []) { + if (issue.kind) { + continue + } + const message = issue.message.trim() + if (message) { + reasons.add(message) + } + if (reasons.size === SKIPPED_TRANSCRIPT_REASON_LIMIT) { + break + } + } + return [...reasons] +} From be3095d24a1c854dc6b159e67cf71a3599fece84 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 06:42:44 -0700 Subject: [PATCH 2/2] Keep mobile history notices inside the existing scroll viewport --- .../MobileAgentSessionHistoryList.tsx | 14 +- .../MobileAgentSessionHistoryPanel.tsx | 54 +++--- .../agent-history-notice-scroll.test.tsx | 162 ++++++++++++++++++ .../src/agent-history/agent-history-styles.ts | 10 ++ 4 files changed, 213 insertions(+), 27 deletions(-) create mode 100644 mobile/src/agent-history/agent-history-notice-scroll.test.tsx diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryList.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryList.tsx index 9bc7ceedd911..7cdba75aedc3 100644 --- a/mobile/src/agent-history/MobileAgentSessionHistoryList.tsx +++ b/mobile/src/agent-history/MobileAgentSessionHistoryList.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo, useState, type ReactElement } from 'react' import { ActivityIndicator, Pressable, RefreshControl, SectionList, Text, View } from 'react-native' import { Play } from 'lucide-react-native' import { colors } from '../theme/mobile-theme' @@ -22,6 +22,8 @@ type Props = { resumeActionStateBySessionId?: ReadonlyMap onResume?: (session: AiVaultSession) => void | Promise onRefresh: () => void + header?: ReactElement + emptyState?: ReactElement } export function MobileAgentSessionHistoryList({ @@ -31,7 +33,9 @@ export function MobileAgentSessionHistoryList({ showCurrentWorktreeBadges, resumeActionStateBySessionId, onResume, - onRefresh + onRefresh, + header, + emptyState }: Props) { const [expandedId, setExpandedId] = useState(null) @@ -63,10 +67,14 @@ export function MobileAgentSessionHistoryList({ return ( card.id} stickySectionHeadersEnabled={false} - contentContainerStyle={styles.list} + contentContainerStyle={[styles.list, sections.length === 0 && styles.emptyList]} + ListHeaderComponent={header} + ListHeaderComponentStyle={styles.listHeader} + ListEmptyComponent={emptyState} refreshControl={ ([]) const [worktreesLoaded, setWorktreesLoaded] = useState(false) const [query, setQuery] = useState('') + const emptyMessage = query + ? 'No sessions match your search.' + : 'No past agent sessions in this scope.' const [resumingSessionId, setResumingSessionId] = useState(null) const [resumeMessage, setResumeMessage] = useState(null) const now = useNow(30_000) @@ -339,30 +342,33 @@ export function MobileAgentSessionHistoryPanel({ autoCorrect={false} /> - - {resumeMessage ? ( - - {resumeMessage} - - ) : null} - {sections.length === 0 ? ( - - No agent sessions - - {query ? 'No sessions match your search.' : 'No past agent sessions in this scope.'} - - - ) : ( - void onRefresh()} - /> - )} + void onRefresh()} + header={ + issues.length > 0 || resumeMessage ? ( + <> + + {resumeMessage ? ( + + {resumeMessage} + + ) : null} + + ) : undefined + } + emptyState={ + + No agent sessions + {emptyMessage} + + } + /> )} diff --git a/mobile/src/agent-history/agent-history-notice-scroll.test.tsx b/mobile/src/agent-history/agent-history-notice-scroll.test.tsx new file mode 100644 index 000000000000..c013e6627569 --- /dev/null +++ b/mobile/src/agent-history/agent-history-notice-scroll.test.tsx @@ -0,0 +1,162 @@ +import type { ReactElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { AiVaultScanIssue, AiVaultSession } from '../../../src/shared/ai-vault-types' + +const state = vi.hoisted(() => { + const sessions: AiVaultSession[] = [] + const issues: AiVaultScanIssue[] = [] + return { sessions, issues, refresh: vi.fn(() => Promise.resolve()) } +}) + +vi.mock('react-native', async () => { + const { createElement, cloneElement } = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + Pressable: 'Pressable', + RefreshControl: 'RefreshControl', + Text: 'Text', + TextInput: 'TextInput', + View: 'View', + StyleSheet: { create: (value: unknown) => value }, + SectionList: ({ + sections, + ListHeaderComponent, + ListEmptyComponent, + renderItem, + ...props + }: { + sections: { data: unknown[] }[] + ListHeaderComponent?: ReactElement + ListEmptyComponent?: ReactElement + renderItem: (args: { item: unknown }) => ReactElement + }) => + createElement( + 'SectionList', + { ...props, sections }, + ListHeaderComponent, + sections.length === 0 + ? ListEmptyComponent + : sections.flatMap((section, group) => + section.data.map((item, index) => + cloneElement(renderItem({ item }), { key: `${group}:${index}` }) + ) + ) + ) + } +}) +vi.mock('react-native-safe-area-context', () => ({ SafeAreaView: 'SafeAreaView' })) +vi.mock('lucide-react-native', () => ({ ChevronLeft: 'Icon', Play: 'Icon', RefreshCw: 'Icon' })) +vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: () => null })) +vi.mock('../navigation/route-handoff', () => ({ + useRouteHandoff: () => ({ back: vi.fn(), push: vi.fn() }) +})) +vi.mock('../transport/client-context', () => ({ + useHostClient: () => ({ client: null, state: 'disconnected' }) +})) +vi.mock('../platform/haptics', () => ({ triggerError: vi.fn(), triggerSuccess: vi.fn() })) +vi.mock('../hooks/use-now', () => ({ useNow: () => Date.parse('2026-10-02T00:00:00Z') })) +vi.mock('./use-mobile-agent-history-state', () => ({ + useMobileAgentHistoryState: () => ({ + scope: 'all', + screenState: { kind: 'ready', sessions: state.sessions, issues: state.issues }, + refreshing: false, + hostStatusResult: null, + activeWorktreePath: null, + scopeFilterPaths: [], + onSelectScope: vi.fn(), + onRefresh: state.refresh, + retry: null + }) +})) + +import { MobileAgentSessionHistoryPanel } from './MobileAgentSessionHistoryPanel' +import { styles } from './agent-history-styles' + +let tree: ReactTestRenderer | undefined + +beforeEach(() => { + state.sessions = [] + state.issues = Array.from({ length: 500 }, (_, index) => ({ + agent: 'opencode2', + kind: 'scope', + executionHostId: 'ssh:history-host', + path: `/history/source-${index}/opencode.db`, + message: `Source ${index} unavailable. Refresh to try again.` + })) + state.refresh.mockClear() +}) + +afterEach(() => { + act(() => tree?.unmount()) + tree = undefined +}) + +function mount() { + act(() => { + tree = create() + }) + if (!tree) { + throw new Error('History panel did not mount') + } + return tree.root +} + +function visibleSession(): AiVaultSession { + return { + id: 'local:opencode2:session', + executionHostId: 'local', + agent: 'opencode2', + sessionId: 'session', + title: 'Existing history remains browsable', + cwd: '/history/project', + branch: null, + model: null, + filePath: '/history/opencode.db', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-10-01T00:00:00Z', + messageCount: 2, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: '', + subagent: null + } +} + +it('keeps every source notice and session inside the same bounded scroll viewport', () => { + state.sessions = [visibleSession()] + const root = mount() + const list = root.find((node) => String(node.type) === 'SectionList') + const notice = (node: { props: { children?: unknown } }) => + typeof node.props.children === 'string' && node.props.children.startsWith('Source ') + + expect(root.findAll(notice)).toHaveLength(500) + expect(list.findAll(notice)).toHaveLength(500) + expect(list.findAll((node) => node.props.children === state.sessions[0]?.title)).toHaveLength(1) + expect(list.props.style).toEqual(styles.listViewport) + expect( + list.findAll((node) => node.props.accessibilityLabel === 'Refresh agent sessions') + ).toEqual([]) + expect(list.findAll((node) => String(node.type) === 'TextInput')).toEqual([]) + + const refresh = root.find((node) => node.props.accessibilityLabel === 'Refresh agent sessions') + act(() => refresh.props.onPress()) + expect(state.refresh).toHaveBeenCalledOnce() +}) + +it('keeps the empty state and pull-to-refresh reachable below a long notice list', () => { + const root = mount() + const list = root.find((node) => String(node.type) === 'SectionList') + + expect(list.props.contentContainerStyle).toContain(styles.emptyList) + expect(list.findAll((node) => node.props.children === 'No agent sessions')).toHaveLength(1) + expect(list.findAll((node) => node.props.children === state.issues.at(-1)?.message)).toHaveLength( + 1 + ) + act(() => list.props.refreshControl.props.onRefresh()) + expect(state.refresh).toHaveBeenCalledOnce() +}) diff --git a/mobile/src/agent-history/agent-history-styles.ts b/mobile/src/agent-history/agent-history-styles.ts index cec41046f3be..8d4e5646c529 100644 --- a/mobile/src/agent-history/agent-history-styles.ts +++ b/mobile/src/agent-history/agent-history-styles.ts @@ -85,6 +85,16 @@ export const styles = StyleSheet.create({ paddingTop: spacing.sm, paddingBottom: spacing.xl }, + listViewport: { + flex: 1 + }, + emptyList: { + flexGrow: 1 + }, + listHeader: { + marginHorizontal: -spacing.md, + marginTop: -spacing.sm + }, groupHeader: { flexDirection: 'row', alignItems: 'center',