From 7d0b481e22be06f26949d3feac424a8602ec79ca Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 25 Jun 2026 23:27:34 +0100 Subject: [PATCH] patrol: show actionable state badges on collapsed Patrol finding rows Patrol findings hide raw investigation-status and investigation-outcome badges per the patrol-intelligence contract, but the contract explicitly allows plain-language actionable state labels (approval required, investigating, verifying fix, fix failed). This adds a getPatrolFinding- ActionableState helper and renders the resulting badge on the collapsed Patrol row between the severity badge and the title, so the operator sees each finding's state without expanding. The badge is absent when no actionable state applies, leaving the default new-finding row clean. --- docs/release-control/v6/internal/status.json | 8 ++-- .../src/components/AI/FindingsPanel.tsx | 15 +++++++ .../AI/__tests__/FindingsPanel.test.ts | 6 +++ .../utils/__tests__/aiFindingWorkType.test.ts | 43 ++++++++++++++++++- .../src/utils/aiFindingPresentation.ts | 29 +++++++++++++ 5 files changed, 96 insertions(+), 5 deletions(-) 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) {