import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AssessorApiClient } from '../../api/assessor-api-client'; import { useEnginePreferences } from '../../app/engine-preferences'; import type { AiOverviewResponse, AiOverviewResult } from '../ai-overview'; import { statusPollApi } from './status-poll-client'; import { useBackendWorkActivity } from './use-backend-work-activity'; import './ai-overview.css'; import type { ReactNode } from 'react'; const assessorApi = new AssessorApiClient(); export interface AiOverviewController { /** False while the Compliance Engine is switched off: no request, no tab. */ available: boolean; response?: AiOverviewResponse; loading: boolean; generating: boolean; error?: string; generate(): Promise; } export function useAiOverview(requestedProjectId: string | undefined, assessmentIds: string[] = [], scanRunIds: string[] = []): AiOverviewController { const available = useEnginePreferences().compliance; const projectId = available ? requestedProjectId : undefined; const [storedResponse, setStoredResponse] = useState<{ scope: { key: string }; value: AiOverviewResponse }>(); const [loading, setLoading] = useState(false); const [generating, setGenerating] = useState(false); const [error, setError] = useState(); const assessmentKey = [...new Set(assessmentIds)].sort().join('\u0000'); const scanRunKey = [...new Set(scanRunIds)].sort().join('\u0000'); const normalizedAssessmentIds = useMemo(() => assessmentKey ? assessmentKey.split('\u0000') : [], [assessmentKey]); const normalizedScanRunIds = useMemo(() => scanRunKey ? scanRunKey.split('\u0000') : [], [scanRunKey]); const scopeKey = `${projectId ?? ''}\u0001${assessmentKey}\u0001${scanRunKey}`; const activeScope = useRef({ key: scopeKey }); if (activeScope.current.key !== scopeKey) activeScope.current = { key: scopeKey }; const requestScope = activeScope.current; const response = storedResponse?.scope === requestScope ? storedResponse.value : undefined; const refresh = useCallback(async (poll = false) => { if (!projectId || (!normalizedAssessmentIds.length && !normalizedScanRunIds.length)) { if (activeScope.current === requestScope) setStoredResponse(undefined); return; } try { const next = await (poll ? statusPollApi : assessorApi).getAiOverview(projectId, normalizedAssessmentIds, normalizedScanRunIds); if (activeScope.current !== requestScope) return; setStoredResponse({ scope: requestScope, value: next }); setError(undefined); } catch (cause) { if (activeScope.current !== requestScope) return; setError(cause instanceof Error ? cause.message : 'AI overview could not be loaded.'); setStoredResponse(current => current?.scope === requestScope && ['queued', 'running'].includes(current.value.state) ? { ...current, value: { ...current.value } } : current); } }, [normalizedAssessmentIds, normalizedScanRunIds, projectId, requestScope]); useEffect(() => { let active = true; setStoredResponse(undefined); setError(undefined); setGenerating(false); setLoading(true); void refresh().finally(() => { if (active) setLoading(false); }); return () => { active = false; }; }, [refresh, scopeKey]); useEffect(() => { if (!response || !['queued', 'running'].includes(response.state)) return; const timer = window.setTimeout(() => void refresh(true), response.pollAfterMs ?? 1_500); return () => window.clearTimeout(timer); }, [refresh, response]); const generate = useCallback(async () => { if (!projectId) return; setGenerating(true); setError(undefined); try { const next = await assessorApi.generateAiOverview(projectId, { assessmentIds: normalizedAssessmentIds, scanRunIds: normalizedScanRunIds, idempotencyKey: createIdempotencyKey() }); if (activeScope.current === requestScope) setStoredResponse({ scope: requestScope, value: next }); } catch (cause) { if (activeScope.current === requestScope) { setError(cause instanceof Error ? cause.message : 'AI overview generation could not be started.'); } } finally { if (activeScope.current === requestScope) setGenerating(false); } }, [normalizedAssessmentIds, normalizedScanRunIds, projectId, requestScope]); // The model runs in the backend; keep one activity open across the status polls until the run ends. useBackendWorkActivity('Generating AI overview', !response ? 'idle' : response.state === 'queued' || response.state === 'running' ? response.state : response.state === 'succeeded' ? 'finished' : response.state === 'failed' ? 'failed' : 'idle'); return { available, response, loading, generating, error, generate }; } export function AiOverviewPanel({ controller, navigation }: { controller: AiOverviewController; navigation?: ReactNode }) { const { response, loading, generating, error, generate } = controller; const run = response?.run; const displayedRun = run?.result ? run : response?.previousSuccessfulRun; const result = displayedRun?.result; const active = response && ['queued', 'running'].includes(response.state); const staleState = response?.stale && run?.status === 'succeeded'; return
{navigation}
Advisory analysis

AI Overview

Generated from the complete privacy-safe export for this assessment timeslot.

{run && {staleState ? 'Stale' : run.status}} {response?.generationEnabled && }
{response?.stale && result &&

The assessment data, model, or analysis contract changed. This saved overview remains visible until you regenerate it.

} {error &&

{error}

} {run?.status === 'failed' && result &&

{run.errorMessage ?? 'Generation failed.'} Showing the previous saved overview.

} {(loading || active || generating) && !result &&

{active ? 'Generation in progress' : 'Loading AI Overview'}

{active ? 'The server is analyzing the complete timeslot. You can leave this tab while it runs.' : 'Loading the latest saved analysis…'}

} {!loading && !result && !active &&

{run?.status === 'failed' ? 'Generation failed' : 'No AI Overview yet'}

{run?.errorMessage ?? (response?.generationEnabled ? 'Generate an advisory summary from the privacy-safe assessment export.' : 'Work LiteLLM is not configured on this server.')}

} {result && }
; } export function AiOverviewResultView({ result, generatedAt, model }: { result: AiOverviewResult; generatedAt?: string; model?: string }) { return
Executive summary

{result.executiveSummary}

Risk
{result.assessment.riskLevel}
Confidence
{result.assessment.confidence}

{result.assessment.rationale}

{result.environmentOverview.summary}

{result.environmentOverview.factsUsed.length > 0 && }
{result.themes.map(theme =>
{theme.id} · {theme.riskLevel} · {theme.confidence} confidence

{theme.title}

{theme.summary}

)}
    {result.priorities.map(priority =>
  1. {priority.rank}
    {priority.timeframe.replace('_', ' ')} · {priority.owner}

    {priority.title}

    {priority.rationale}

    Expected reduction
    {priority.expectedRiskReduction}
    Validation
    {priority.validation}
  2. )}
{result.roadmap.map(phase =>
{phase.phase}

{phase.title}

Tasks

Completion criteria

)}
{result.coverageGaps.length ? result.coverageGaps.map(gap =>

{gap.title}

{gap.impact}

Next step

{gap.nextStep}

) :

No coverage gaps identified.

}
{result.quickWins.length ? result.quickWins.map(win =>

{win.title}

) :

No quick wins identified.

}
Advisory AI output · Schema {result.schemaVersion}{model ? ` · ${model}` : ''}{generatedAt ? ` · ${new Date(generatedAt).toLocaleString('en-US')}` : ''}
; } function AiSection({ title, children }: { title: string; children: ReactNode }) { return

{title}

{children}
; } function TextList({ values }: { values: string[] }) { return values.length ? :

No supported items.

; } /** Questions and assumptions have no count limit; the first entries stay visible and the rest expand on request. */ const COLLAPSED_LIST_ENTRIES = 10; function CollapsibleTextList({ values, noun }: { values: string[]; noun: string }) { const [expanded, setExpanded] = useState(false); const collapsible = values.length > COLLAPSED_LIST_ENTRIES; return <> {collapsible && } ; } function TagList({ values }: { values: string[] }) { return
    {values.map(value =>
  • {value}
  • )}
; } function ReferenceList({ values }: { values: string[] }) { return values.length ?

Evidence: {values.join(', ')}

: null; } function createIdempotencyKey(): string { return globalThis.crypto?.randomUUID?.() ?? `request-${Date.now()}-${Math.random().toString(36).slice(2)}`; }