Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions mobile/src/agent-history/MobileAgentSessionHistoryList.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -22,6 +22,8 @@ type Props = {
resumeActionStateBySessionId?: ReadonlyMap<string, { disabled: boolean; loading: boolean }>
onResume?: (session: AiVaultSession) => void | Promise<void>
onRefresh: () => void
header?: ReactElement
emptyState?: ReactElement
}

export function MobileAgentSessionHistoryList({
Expand All @@ -31,7 +33,9 @@ export function MobileAgentSessionHistoryList({
showCurrentWorktreeBadges,
resumeActionStateBySessionId,
onResume,
onRefresh
onRefresh,
header,
emptyState
}: Props) {
const [expandedId, setExpandedId] = useState<string | null>(null)

Expand Down Expand Up @@ -63,10 +67,14 @@ export function MobileAgentSessionHistoryList({

return (
<SectionList
style={styles.listViewport}
sections={sections}
keyExtractor={(card) => card.id}
stickySectionHeadersEnabled={false}
contentContainerStyle={styles.list}
contentContainerStyle={[styles.list, sections.length === 0 && styles.emptyList]}
ListHeaderComponent={header}
ListHeaderComponentStyle={styles.listHeader}
ListEmptyComponent={emptyState}
refreshControl={
<RefreshControl
refreshing={refreshing}
Expand Down
61 changes: 31 additions & 30 deletions mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -72,6 +73,9 @@ export function MobileAgentSessionHistoryPanel({
const [worktrees, setWorktrees] = useState<Worktree[]>([])
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<string | null>(null)
const [resumeMessage, setResumeMessage] = useState<string | null>(null)
const now = useNow(30_000)
Expand Down Expand Up @@ -338,36 +342,33 @@ export function MobileAgentSessionHistoryPanel({
autoCorrect={false}
/>
</View>
{issues.length > 0 ? (
<View style={styles.noticeBanner}>
<Text style={styles.noticeText}>
{issues.length} {issues.length === 1 ? 'transcript' : 'transcripts'} skipped
</Text>
</View>
) : null}
{resumeMessage ? (
<View style={styles.resumeBanner}>
<Text style={styles.resumeBannerText}>{resumeMessage}</Text>
</View>
) : null}
{sections.length === 0 ? (
<View style={styles.state}>
<Text style={styles.stateTitle}>No agent sessions</Text>
<Text style={styles.stateText}>
{query ? 'No sessions match your search.' : 'No past agent sessions in this scope.'}
</Text>
</View>
) : (
<MobileAgentSessionHistoryList
sections={sections}
sessionsById={sessionsById}
refreshing={refreshing}
showCurrentWorktreeBadges={shouldShowMobileCurrentWorktreeBadge(scope)}
resumeActionStateBySessionId={resumeActionStateBySessionId}
onResume={onResumeSession}
onRefresh={() => void onRefresh()}
/>
)}
<MobileAgentSessionHistoryList
sections={sections}
sessionsById={sessionsById}
refreshing={refreshing}
showCurrentWorktreeBadges={shouldShowMobileCurrentWorktreeBadge(scope)}
resumeActionStateBySessionId={resumeActionStateBySessionId}
onResume={onResumeSession}
onRefresh={() => void onRefresh()}
header={
issues.length > 0 || resumeMessage ? (
<>
<MobileAgentHistoryScanBanners sessions={sessions} issues={issues} />
{resumeMessage ? (
<View style={styles.resumeBanner}>
<Text style={styles.resumeBannerText}>{resumeMessage}</Text>
</View>
) : null}
</>
) : undefined
}
emptyState={
<View style={styles.state}>
<Text style={styles.stateTitle}>No agent sessions</Text>
<Text style={styles.stateText}>{emptyMessage}</Text>
</View>
}
/>
</>
)}
</View>
Expand Down
162 changes: 162 additions & 0 deletions mobile/src/agent-history/agent-history-notice-scroll.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<MobileAgentSessionHistoryPanel hostId="history-host" worktreeId="folder:test" />)
})
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()
})
23 changes: 23 additions & 0 deletions mobile/src/agent-history/agent-history-reply-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 11 additions & 3 deletions mobile/src/agent-history/agent-history-reply-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issues is parsed with a plain z.array(...), so a single row missing agent/path or carrying a non-string message fails the whole agentHistorySessionScanSchema. agentHistorySessionScan uses require-result-or-throw-message, so that failure throws and use-mobile-agent-history-state.ts:138-144 replaces the entire screen with { kind: 'error' }, discarding every readable session because one issue row was malformed.

Session rows deliberately stay z.unknown() for exactly this forward-compat reason, so the issue array should salvage per-row instead. This requires adding salvagingArray to the ../../../src/shared/zod-salvage import.

Technical details
# One malformed issue row fails the entire scan reply

## Affected sites
- `mobile/src/agent-history/agent-history-reply-schema.ts:43-51` — plain `z.array(z.looseObject(...))` propagates any row failure to the whole parse.
- `mobile/src/agent-history/agent-history-reply-schema.test.ts:55-62` — pins the whole-reply failure (`safeParse(...).success === false`) using only `sessions: []`, so it never demonstrates that valid sessions survive the failure.
- `mobile/src/agent-history/use-mobile-agent-history-state.ts:127-144` — the interpret throw becomes `screenState.kind === 'error'`, replacing the valid `sessions`.

## Required outcome
- A malformed issue row must not remove readable sessions; the reply should still publish its valid `sessions` (and any valid issues).

## Suggested approach
- Wrap the row schema in `salvagingArray` so invalid rows drop individually; ~20 mobile reply schemas already use it (`source-control/git-status-reply-schema.ts`, `tasks/task-list-reply-schema.ts`, `session/session-read-reply-schema.ts`, ...). Update the `refuses unreadable banner rows` test to assert the bad row is dropped while valid rows/sessions survive. Unlike a top-level array, here the array is one member of a larger reply, so there is no equivalent JSON-array wire constraint to preserve.

## Open questions for the human
- If refusing the whole reply is intentional (treating any malformed issue row as host incompatibility), that should be stated explicitly — the current docstring only explains why *session* rows stay unknown.
Suggested change
issues: z.array(
issues: salvagingArray(

z.looseObject({
agent: z.string(),
path: z.string(),
message: z.string(),
kind: salvagedOptional('kind', z.string()),
executionHostId: salvagedOptional('executionHostId', z.string())
})
)
})

/**
Expand Down
16 changes: 15 additions & 1 deletion mobile/src/agent-history/agent-history-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -196,7 +206,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: {
Expand Down
Loading
Loading