diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index ee9a817e2..4f0712afa 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -7336,11 +7336,11 @@ { "id": "opencode-coverage-gap-protection-posture-attention-queue", "agent_id": "opencode", - "summary": "Patrol workspace work-type grouping: classify findings by actionable type and surface composition in the workspace header", + "summary": "Patrol row-level actionable state labels: translate hidden investigation state into plain-language badges on collapsed Patrol finding rows", "target_id": "v6-product-lane-expansion", - "claimed_at": "2026-06-25T22:11:19Z", - "heartbeat_at": "2026-06-25T22:11:19Z", - "expires_at": "2026-06-26T00:11:19Z", + "claimed_at": "2026-06-25T22:24:34Z", + "heartbeat_at": "2026-06-25T22:24:34Z", + "expires_at": "2026-06-26T00:24:34Z", "work_item": { "kind": "coverage-gap", "id": "protection-posture-attention-queue" diff --git a/frontend-modern/src/components/AI/FindingsPanel.tsx b/frontend-modern/src/components/AI/FindingsPanel.tsx index 3e7af662b..463488e61 100644 --- a/frontend-modern/src/components/AI/FindingsPanel.tsx +++ b/frontend-modern/src/components/AI/FindingsPanel.tsx @@ -80,6 +80,7 @@ import { getInvestigationStatusLabel, getInvestigationOutcomeSortOrder, getInvestigationStatusBadgeTone, + getPatrolFindingActionableState, } from '@/utils/aiFindingPresentation'; import { copyToClipboard } from '@/utils/clipboard'; @@ -884,6 +885,7 @@ export const FindingsPanel: Component = (props) => { const rowPrimaryAction = getFindingPrimaryActionPresentation(finding); const patrolWorkflow = () => getFindingPatrolWorkflowPresentation(finding, aiIntelligenceStore.patrolPendingApprovals); + const patrolActionableState = () => getPatrolFindingActionableState(finding); const collapsedApprovalAction = () => { const workflow = patrolWorkflow(); return workflow?.stage === 'approval' ? workflow : undefined; @@ -976,6 +978,19 @@ export const FindingsPanel: Component = (props) => { > {severityPresentation.label} + {/* Patrol actionable state — plain-language translation of + investigation state that the contract allows on the + collapsed Patrol row (approval required, investigating, + verifying fix, fix failed). Raw investigation-status and + investigation-outcome badges stay hidden for Patrol + findings; this badge surfaces the operator-facing meaning. */} + + {(state) => ( + + {state().label} + + )} + {/* Alert-triggered badge */} { expect(patrolWorkspaceSource).toContain('workTypeComposition'); }); + it('shows contract-sanctioned actionable state badges on collapsed Patrol rows', () => { + expect(findingsPanelSource).toContain('getPatrolFindingActionableState'); + expect(findingsPanelSource).toContain('patrolActionableState'); + expect(findingsPanelSource).toContain('isPatrolFindingsSource() && patrolActionableState()'); + }); + it('does not stack a default detected loop-state badge on active findings', () => { expect(findingsPanelSource).toContain('const shouldShowLoopStateBadge = () =>'); expect(findingsPanelSource).toContain('!isPatrolFindingsSource()'); diff --git a/frontend-modern/src/utils/__tests__/aiFindingWorkType.test.ts b/frontend-modern/src/utils/__tests__/aiFindingWorkType.test.ts index ce398cb63..6d97a7667 100644 --- a/frontend-modern/src/utils/__tests__/aiFindingWorkType.test.ts +++ b/frontend-modern/src/utils/__tests__/aiFindingWorkType.test.ts @@ -4,8 +4,8 @@ import { classifyPatrolFindingWorkType, getPatrolWorkTypeComposition, getPatrolWorkTypeCompositionClause, + getPatrolFindingActionableState, } from '@/utils/aiFindingPresentation'; -import type { UnifiedFinding } from '@/stores/aiIntelligence'; type ClassifyInput = Parameters[0]; @@ -178,3 +178,44 @@ describe('getPatrolWorkTypeCompositionClause', () => { ).toBe(' — 1 needs approval, 1 failed fix, 2 recurring'); }); }); + +describe('getPatrolFindingActionableState', () => { + it('returns undefined for a plain new finding', () => { + expect(getPatrolFindingActionableState(makeFinding())).toBeUndefined(); + }); + + it('returns approval required for fix_queued', () => { + expect( + getPatrolFindingActionableState(makeFinding({ investigationOutcome: 'fix_queued' })), + ).toEqual({ label: 'Approval required', tone: 'warning' }); + }); + + it.each(['fix_failed', 'fix_verification_failed', 'cannot_fix', 'timed_out'] as const)( + 'returns fix failed for %s', + (outcome) => { + expect(getPatrolFindingActionableState(makeFinding({ investigationOutcome: outcome }))).toEqual( + { label: 'Fix failed', tone: 'danger' }, + ); + }, + ); + + it('returns investigating for a running investigation', () => { + expect( + getPatrolFindingActionableState(makeFinding({ investigationStatus: 'running' })), + ).toEqual({ label: 'Investigating', tone: 'info' }); + }); + + it('returns verifying fix for fix_executed', () => { + expect( + getPatrolFindingActionableState(makeFinding({ investigationOutcome: 'fix_executed' })), + ).toEqual({ label: 'Verifying fix', tone: 'info' }); + }); + + it('returns undefined for non-active findings', () => { + expect( + getPatrolFindingActionableState( + makeFinding({ status: 'resolved', investigationOutcome: 'fix_queued' }), + ), + ).toBeUndefined(); + }); +}); diff --git a/frontend-modern/src/utils/aiFindingPresentation.ts b/frontend-modern/src/utils/aiFindingPresentation.ts index 2a372ef08..3dbb815cd 100644 --- a/frontend-modern/src/utils/aiFindingPresentation.ts +++ b/frontend-modern/src/utils/aiFindingPresentation.ts @@ -658,6 +658,35 @@ export function getPatrolWorkTypeCompositionClause( return ` — ${parts.join(', ')}`; } +export interface PatrolActionableStatePresentation { + label: string; + tone: MetadataBadgeTone; +} + +export function getPatrolFindingActionableState( + finding: Pick, +): PatrolActionableStatePresentation | undefined { + if (finding.status !== 'active') return undefined; + + if (finding.investigationOutcome === 'fix_queued') { + return { label: 'Approval required', tone: 'warning' }; + } + + if (isFailedFixOutcome(finding.investigationOutcome)) { + return { label: 'Fix failed', tone: 'danger' }; + } + + if (finding.investigationStatus === 'running') { + return { label: 'Investigating', tone: 'info' }; + } + + if (finding.investigationOutcome === 'fix_executed') { + return { label: 'Verifying fix', tone: 'info' }; + } + + return undefined; +} + export const getPatrolFindingIssueCountLabel = (count: number): string => { const normalized = Number.isFinite(count) ? Math.max(0, Math.trunc(count)) : 0; if (normalized === 1) {