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) @@ -338,36 +342,33 @@ export function MobileAgentSessionHistoryPanel({ autoCorrect={false} /> - {issues.length > 0 ? ( - - - {issues.length} {issues.length === 1 ? 'transcript' : 'transcripts'} skipped - - - ) : null} - {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-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..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', @@ -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: { 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] +}