diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index af92e1780..49e0ae2c8 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,19 +1,15 @@ { "version": 1, - "base_sha": "bcea56db970cd59f2f703584dc3ad2c88bc40910", - "verified_at": "2026-08-18T12:14:30Z", + "base_sha": "2eeb643afe3f9b4e4762d536081467014fffbda1", + "verified_at": "2026-08-18T14:02:44Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/actions/ActionReviewDialog.tsx", - "frontend-modern/src/features/actions/actionPresentation.ts", - "frontend-modern/src/types/actionAudit.ts" + "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx" ], "content_sha256": { - "frontend-modern/src/features/actions/ActionReviewDialog.tsx": "5e489d440ce9b1e9f106a543a79b09d0b4d73aed65c9757688efa39b2d6fa833", - "frontend-modern/src/features/actions/actionPresentation.ts": "776904de782fb0e208f28de7eb6e7d8ea9ac8746c3b2fa1cd7ee9b6ff1bcb985", - "frontend-modern/src/types/actionAudit.ts": "435934beca93e8683ec5177e051b8a8d06f2d3eb4fb09a082e4ead30282bbc32" + "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx": "b15a41c788a4b197cadc8e5087f307ba0acec1457dd78e98ca739bef161bc1e0" }, - "routes": ["/actions", "/patrol"], + "routes": ["/patrol"], "viewports": [ { "width": 1280, @@ -25,17 +21,23 @@ } ], "states": [ - "Actions Open queue with Patrol-origin records and a selected governed review", - "Older Patrol action review with an honest Open Patrol fallback and no record-specific claim", - "Patrol Inbox opened with a canonical encoded attention record selected in the detail workspace", - "Mobile action review with the Patrol return control visible above the decision packet", - "Mobile Patrol exact-record selection with the intended decision detail visible" + "Decision inbox before and after reviewing an issue", + "Expanded lifecycle explanation with corrected Mark reviewed behavior", + "Review success notice with the handled-items shortcut", + "Reviewed and suppressed list with distinct Reviewed and Suppressed badges", + "Selected reviewed item in the desktop split workspace", + "Selected reviewed item in the mobile detail workspace", + "Handled-items empty state after restoration", + "Restored decision visible in the active inbox" ], "interactions": [ - "Opened a Patrol-origin action from the live Actions queue and used Open Patrol to return to the Patrol Inbox", - "Navigated through the canonical encoded attention route and confirmed it selected the exact operational record", - "Repeated the selected action review and exact Patrol selection at a 390 by 844 viewport", - "Verified the mobile dialog remained within the viewport and document width equalled the 390-pixel viewport", - "Confirmed the final browser diagnostics contained no warning or error entries" + "Opened a current decision and expanded More ways to manage this issue", + "Marked the decision reviewed and confirmed automatic advancement plus remaining-work feedback", + "Opened Reviewed and suppressed from the success notice and selected the reviewed record", + "Returned the reviewed record to the decision inbox and confirmed it reappeared", + "Suppressed a decision with an explicit reason and bounded return time", + "Opened the suppressed record from Reviewed and suppressed and returned it to active attention", + "Repeated the handled-record selection at 390 by 844 and confirmed document width remained 390 pixels", + "Verified handled-item keyboard labels and confirmed the final browser diagnostics had no warnings or errors" ] } diff --git a/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx b/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx index b31f13c15..ec226a702 100644 --- a/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx +++ b/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx @@ -68,6 +68,7 @@ import { const PRIMARY_EVIDENCE_LIMIT = 3; const PRIMARY_DECISION_LIMIT = 5; +type PatrolAttentionView = 'inbox' | 'handled'; const normalizeComparableCopy = (value?: string | null) => (value ?? '') @@ -145,6 +146,7 @@ export function PatrolAttentionWorkbench( const [lifecycleBusy, setLifecycleBusy] = createSignal(false); const [lifecycleError, setLifecycleError] = createSignal(''); const [reviewNotice, setReviewNotice] = createSignal(''); + const [attentionView, setAttentionView] = createSignal('inbox'); const [reviewOrder, setReviewOrder] = createSignal([]); const [showAllDecisions, setShowAllDecisions] = createSignal(false); const itemButtons = new Map(); @@ -157,12 +159,19 @@ export function PatrolAttentionWorkbench( props.autonomyLocked ? 'monitor' : (props.autonomyLevel ?? 'monitor'), ); const autonomyExperience = createMemo(() => PATROL_AUTONOMY_EXPERIENCE[effectiveAutonomyLevel()]); + const handledCount = createMemo( + () => (summary()?.acknowledgedCount ?? 0) + (summary()?.suppressedCount ?? 0), + ); const attention = createMemo(() => { const items = patrolAttentionStore.items(); + const visibleItems = + attentionView() === 'handled' + ? items.filter((item) => item.state === 'acknowledged' || item.state === 'suppressed') + : items; return partitionPatrolAttention( - Array.isArray(items) ? items : [], - props.autonomyLevel ?? 'monitor', - props.autonomyLocked ?? false, + Array.isArray(visibleItems) ? visibleItems : [], + attentionView() === 'handled' ? 'monitor' : (props.autonomyLevel ?? 'monitor'), + attentionView() === 'handled' ? true : (props.autonomyLocked ?? false), ); }); const sortedDecisions = createMemo(() => sortPatrolAttentionDecisions(attention().needsUser)); @@ -199,6 +208,10 @@ export function PatrolAttentionWorkbench( const briefingHeadline = createMemo(() => { if (patrolAttentionStore.loading() && !summary()) return 'Building your current briefing'; const count = attention().needsUser.length; + if (attentionView() === 'handled') { + if (count === 0) return 'No reviewed or suppressed issues'; + return `${count} reviewed or suppressed ${count === 1 ? 'issue' : 'issues'}`; + } if (count === 0) return 'No decisions are waiting'; return `${count} ${count === 1 ? 'decision needs' : 'decisions need'} you`; }); @@ -231,6 +244,16 @@ export function PatrolAttentionWorkbench( void patrolAttentionStore.select(null); queueMicrotask(() => itemButtons.get(previous)?.focus()); }; + const switchAttentionView = (nextView: PatrolAttentionView) => { + setAttentionView(nextView); + setReviewNotice(''); + setReviewOrder([]); + setShowAllDecisions(false); + setSelectedItemId(''); + replaceAttentionLocation(''); + void patrolAttentionStore.select(null); + void patrolAttentionStore.load(nextView === 'handled' ? 'all' : 'active'); + }; const reviewAction = async ( item: AttentionItem, offer: AttentionActionOffer, @@ -283,16 +306,20 @@ export function PatrolAttentionWorkbench( setLifecycleError(''); try { await operation(); - await patrolAttentionStore.load(patrolAttentionStore.filter()); + await patrolAttentionStore.load(attentionView() === 'handled' ? 'all' : 'active'); if (options.advanceAfter) { const remaining = orderedDecisions(); const next = remaining.find((decision) => decision.item.id === nextCandidateId) ?? remaining[0]; const successLabel = options.successLabel ?? 'Decision updated'; setReviewNotice( - remaining.length > 0 - ? `${successLabel}. ${remaining.length} ${remaining.length === 1 ? 'decision remains' : 'decisions remain'}.` - : `${successLabel}. Your decision inbox is clear.`, + attentionView() === 'handled' + ? remaining.length > 0 + ? `${successLabel}. ${remaining.length} ${remaining.length === 1 ? 'handled issue remains' : 'handled issues remain'}.` + : `${successLabel}. No other handled issues remain.` + : remaining.length > 0 + ? `${successLabel}. ${remaining.length} ${remaining.length === 1 ? 'decision remains' : 'decisions remain'}.` + : `${successLabel}. Your decision inbox is clear.`, ); if (next) { selectItem(next.item.id, true); @@ -351,12 +378,16 @@ export function PatrolAttentionWorkbench(

- {autonomyExperience().needsYouDescription} + {attentionView() === 'handled' + ? 'Issues stay here until they return to the inbox or resolve.' + : autonomyExperience().needsYouDescription}

- + {(decision) => ( + + } + > + + 0}>
- 0}> + 0}>

{attention().quiet.length} other current{' '} {attention().quiet.length === 1 ? 'issue is' : 'issues are'} continuing without a @@ -408,7 +459,16 @@ export function PatrolAttentionWorkbench( class="flex items-center gap-2 border-b border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-200 sm:px-6" >

@@ -458,6 +519,7 @@ export function PatrolAttentionWorkbench( selectedDecisionIndex() >= 0 ? selectedDecisionIndex() + 1 : undefined } queueCount={() => orderedDecisions().length} + queueLabel={attentionView() === 'handled' ? 'Handled issue' : 'Decision'} canPrevious={() => Boolean(previousDecision())} canNext={() => Boolean(nextDecision())} onPrevious={() => { @@ -475,7 +537,10 @@ export function PatrolAttentionWorkbench( }) } onUnacknowledge={(itemId) => - changeLifecycle(() => unacknowledgePatrolAttention(itemId)) + changeLifecycle(() => unacknowledgePatrolAttention(itemId), { + advanceAfter: attentionView() === 'handled', + successLabel: 'Returned to decision inbox', + }) } onSuppress={(itemId, reason, expiresAt) => changeLifecycle(() => suppressPatrolAttention(itemId, reason, expiresAt), { @@ -483,7 +548,12 @@ export function PatrolAttentionWorkbench( successLabel: 'Suppressed temporarily', }) } - onUnsuppress={(itemId) => changeLifecycle(() => unsuppressPatrolAttention(itemId))} + onUnsuppress={(itemId) => + changeLifecycle(() => unsuppressPatrolAttention(itemId), { + advanceAfter: attentionView() === 'handled', + successLabel: 'Returned to decision inbox', + }) + } onOpenFindings={props.onOpenFindings} />
@@ -510,6 +580,7 @@ function AttentionList(props: { selectedItemId: string; itemButtons: Map; onSelect: (itemId: string) => void; + view: PatrolAttentionView; }) { const hasQuietWork = () => { const items = patrolAttentionStore.items(); @@ -547,9 +618,21 @@ function AttentionList(props: { > 0} - fallback={} + fallback={ + + } > -
    +
      {(decision) => { const item = decision.item; @@ -591,6 +674,11 @@ function AttentionList(props: { {formatLabel(item.severity)} + + + {item.state === 'acknowledged' ? 'Reviewed' : 'Suppressed'} + + {displayTitle()} @@ -636,7 +724,7 @@ function AttentionList(props: { ); } -function AttentionEmptyState(props: { hasQuietWork: boolean }) { +function AttentionEmptyState(props: { hasQuietWork: boolean; view: PatrolAttentionView }) { const summary = () => patrolAttentionStore.summary(); const activeFilter = () => patrolAttentionStore.filter() === 'active'; const trustworthyCalm = () => @@ -651,30 +739,44 @@ function AttentionEmptyState(props: { hasQuietWork: boolean }) { class="flex min-h-52 flex-col items-center justify-center px-6 py-10 text-center" > - ); @@ -695,6 +797,7 @@ function AttentionDetail(props: { lifecycleError: string; queuePosition: Accessor; queueCount: Accessor; + queueLabel: 'Decision' | 'Handled issue'; canPrevious: Accessor; canNext: Accessor; onPrevious: () => void; @@ -795,23 +898,27 @@ function AttentionDetail(props: {

      - - Decision {props.queuePosition()} of {props.queueCount()} + + {props.queueLabel} {props.queuePosition()} of {props.queueCount()}

      @@ -1309,8 +1416,9 @@ function AttentionLifecycleControls(props: {

      - Mark reviewed removes this occurrence from today's decision inbox while keeping its - record. Suppression hides it only until the selected return time. + Mark reviewed removes this occurrence from the decision inbox until it resolves or you + return it to open. Suppression hides it only until the selected return time. Both + remain available under Reviewed and suppressed.

      For a permanent change,{' '} diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx b/frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx index b54bfcc80..8220f9d01 100644 --- a/frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx +++ b/frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx @@ -530,20 +530,37 @@ describe('PatrolAttentionWorkbench', () => { it('marks an item reviewed and advances through the decision queue', async () => { const active = item(); const next = item({ id: 'record-2', title: 'Database replication is delayed' }); - apiMocks.getList - .mockResolvedValueOnce( - listResponse([active, next], summary({ activeCount: 2, openCount: 2, calm: false })), - ) - .mockResolvedValue( - listResponse( - [next], - summary({ activeCount: 1, openCount: 1, acknowledgedCount: 1, calm: false }), - ), + const reviewed = item({ state: 'acknowledged' }); + let isReviewed = false; + apiMocks.getList.mockImplementation((filter: string) => { + if (!isReviewed) { + return Promise.resolve( + listResponse([active, next], summary({ activeCount: 2, openCount: 2, calm: false })), + ); + } + const responseSummary = summary({ + activeCount: 1, + openCount: 1, + acknowledgedCount: 1, + calm: false, + }); + return Promise.resolve( + filter === 'all' + ? listResponse([reviewed, next], responseSummary) + : listResponse([next], responseSummary), ); + }); apiMocks.getDetail.mockImplementation((itemId: string) => - Promise.resolve(detail(itemId === active.id ? active : next)), + Promise.resolve(detail(itemId === active.id ? (isReviewed ? reviewed : active) : next)), ); - apiMocks.acknowledge.mockResolvedValue({ success: true }); + apiMocks.acknowledge.mockImplementation(() => { + isReviewed = true; + return Promise.resolve({ success: true }); + }); + apiMocks.unacknowledge.mockImplementation(() => { + isReviewed = false; + return Promise.resolve({ success: true }); + }); renderWorkbench(); fireEvent.click( @@ -578,6 +595,28 @@ describe('PatrolAttentionWorkbench', () => { await screen.findByRole('complementary', { name: 'Database replication is delayed' }), ).toBeInTheDocument(); expect(window.location.search).toBe('?attention=record-2'); + + fireEvent.click(screen.getByRole('button', { name: 'Review handled issues' })); + expect( + await screen.findByRole('heading', { name: '1 reviewed or suppressed issue' }), + ).toBeInTheDocument(); + expect(apiMocks.getList).toHaveBeenLastCalledWith('all'); + expect(screen.getByText('Reviewed', { exact: true })).toBeInTheDocument(); + + fireEvent.click( + screen.getByRole('button', { + name: 'Open Database VM ยท Disk pressure', + }), + ); + fireEvent.click(await screen.findByRole('button', { name: 'Return to decision inbox' })); + + await waitFor(() => expect(apiMocks.unacknowledge).toHaveBeenCalledWith('record-1')); + expect(await screen.findByRole('status')).toHaveTextContent( + 'Returned to decision inbox. No other handled issues remain.', + ); + expect( + screen.getByRole('heading', { name: 'No reviewed or suppressed issues' }), + ).toBeInTheDocument(); }); it('requires an explicit reason and bounded duration before temporary suppression', async () => { @@ -641,6 +680,7 @@ describe('PatrolAttentionWorkbench', () => { expect(await screen.findByRole('status')).toHaveTextContent( 'Suppressed temporarily. Your decision inbox is clear.', ); + expect(screen.getByRole('button', { name: 'Review handled issues' })).toBeInTheDocument(); expect( await screen.findByRole('heading', { name: 'Nothing needs you right now' }), ).toBeInTheDocument(); @@ -668,7 +708,10 @@ describe('PatrolAttentionWorkbench', () => { }), ); - expect(await screen.findByText(/Mark reviewed removes this occurrence/i)).toBeInTheDocument(); + expect( + await screen.findByText(/Mark reviewed removes this occurrence from the decision inbox/i), + ).toHaveTextContent(/until it resolves or you return it to open/i); + expect(screen.queryByText(/today's decision inbox/i)).not.toBeInTheDocument(); expect(screen.getByRole('link', { name: 'adjust alert thresholds' })).toHaveAttribute( 'href', '/alerts/thresholds',