fix(patrol): make handled decisions recoverable

Contract-Neutral: Patrol lifecycle recovery UI fix with no API, primitive, or subsystem contract change
This commit is contained in:
rcourtman 2026-08-18 15:05:42 +01:00
parent 2eeb643afe
commit e7cc3f4b67
3 changed files with 227 additions and 74 deletions

View file

@ -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"
]
}

View file

@ -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<PatrolAttentionView>('inbox');
const [reviewOrder, setReviewOrder] = createSignal<string[]>([]);
const [showAllDecisions, setShowAllDecisions] = createSignal(false);
const itemButtons = new Map<string, HTMLButtonElement>();
@ -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(
</h2>
<Show when={attention().needsUser.length === 0}>
<p class="mt-1 text-sm leading-5 text-muted">
{autonomyExperience().needsYouDescription}
{attentionView() === 'handled'
? 'Issues stay here until they return to the inbox or resolve.'
: autonomyExperience().needsYouDescription}
</p>
</Show>
</div>
<div class="flex flex-wrap items-center gap-2 sm:justify-end">
<Show when={!selectedItemId() && highestPriorityDecision()}>
<Show
when={attentionView() === 'inbox' && !selectedItemId() && highestPriorityDecision()}
>
{(decision) => (
<Button
variant="primary"
@ -369,6 +400,26 @@ export function PatrolAttentionWorkbench(
</Button>
)}
</Show>
<Show
when={attentionView() === 'inbox' && handledCount() > 0}
fallback={
<Show when={attentionView() === 'handled'}>
<Button
variant="secondary"
size="sm"
class="gap-1.5"
onClick={() => switchAttentionView('inbox')}
>
<ArrowLeftIcon class="h-4 w-4" aria-hidden="true" />
Back to decision inbox
</Button>
</Show>
}
>
<Button variant="secondary" size="sm" onClick={() => switchAttentionView('handled')}>
Reviewed and suppressed ({handledCount()})
</Button>
</Show>
<Show when={(props.pendingActionCount ?? 0) > 0}>
<ButtonLink href="/actions" variant="secondary" size="sm" class="gap-1.5">
<ClipboardCheckIcon class="h-4 w-4" aria-hidden="true" />
@ -392,7 +443,7 @@ export function PatrolAttentionWorkbench(
</Button>
</div>
</div>
<Show when={attention().quiet.length > 0}>
<Show when={attentionView() === 'inbox' && attention().quiet.length > 0}>
<p class="mt-3 text-xs leading-5 text-muted">
{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"
>
<CheckCircleIcon class="h-4 w-4 shrink-0" aria-hidden="true" />
{notice()}
<span>{notice()}</span>
<Show when={attentionView() === 'inbox' && handledCount() > 0}>
<button
type="button"
class="ml-auto shrink-0 underline underline-offset-2 hover:no-underline focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600"
onClick={() => switchAttentionView('handled')}
>
Review handled issues
</button>
</Show>
</div>
)}
</Show>
@ -424,6 +484,7 @@ export function PatrolAttentionWorkbench(
selectedItemId={selectedItemId()}
itemButtons={itemButtons}
onSelect={selectItem}
view={attentionView()}
/>
<Show when={attention().needsUser.length > PRIMARY_DECISION_LIMIT}>
<div class="border-t border-border px-4 py-3 text-center sm:px-5">
@ -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}
/>
</div>
@ -510,6 +580,7 @@ function AttentionList(props: {
selectedItemId: string;
itemButtons: Map<string, HTMLButtonElement>;
onSelect: (itemId: string) => void;
view: PatrolAttentionView;
}) {
const hasQuietWork = () => {
const items = patrolAttentionStore.items();
@ -547,9 +618,21 @@ function AttentionList(props: {
>
<Show
when={props.decisions.length > 0}
fallback={<AttentionEmptyState hasQuietWork={hasQuietWork()} />}
fallback={
<AttentionEmptyState
hasQuietWork={props.view === 'inbox' && hasQuietWork()}
view={props.view}
/>
}
>
<ul class="divide-y divide-border" aria-label="Patrol attention items">
<ul
class="divide-y divide-border"
aria-label={
props.view === 'handled'
? 'Reviewed and suppressed Patrol items'
: 'Patrol attention items'
}
>
<For each={props.decisions}>
{(decision) => {
const item = decision.item;
@ -591,6 +674,11 @@ function AttentionList(props: {
<MetadataBadge tone={severityTone(item)} size="xs" shape="rounded">
{formatLabel(item.severity)}
</MetadataBadge>
<Show when={props.view === 'handled'}>
<MetadataBadge tone="neutral" size="xs" shape="rounded">
{item.state === 'acknowledged' ? 'Reviewed' : 'Suppressed'}
</MetadataBadge>
</Show>
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-base-content">
{displayTitle()}
</span>
@ -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"
>
<Show
when={trustworthyCalm() || props.hasQuietWork}
when={props.view === 'inbox'}
fallback={
<>
<ClockIcon class="h-8 w-8 text-muted" aria-hidden="true" />
<h3 class="mt-3 text-sm font-semibold text-base-content">No items in this view</h3>
<CheckCircleIcon class="h-9 w-9 text-emerald-500" aria-hidden="true" />
<h3 class="mt-3 text-sm font-semibold text-base-content">Handled issues appear here</h3>
<p class="mt-1 max-w-md text-xs leading-5 text-muted">
{summary()?.coverageState === 'partial'
? 'The Inbox is empty, but protection context is incomplete. Pulse is not treating that gap as proof of health.'
: 'Refresh the Inbox to check the current evaluation.'}
Issues you handle from the decision inbox will remain available here until they return
to the inbox or resolve.
</p>
</>
}
>
<CheckCircleIcon class="h-9 w-9 text-emerald-500" aria-hidden="true" />
<h3 class="mt-3 text-sm font-semibold text-base-content">Nothing needs you right now</h3>
<p class="mt-1 max-w-md text-xs leading-5 text-muted">
{props.hasQuietWork
? 'Patrol can continue with the current issues under this mode.'
: 'The current operational evaluation has no active items.'}
<Show when={summary()?.evaluatedAt}>
{' Checked '}
{formatRelativeTime(summary()!.evaluatedAt, { compact: true })}.
</Show>
</p>
<Show
when={trustworthyCalm() || props.hasQuietWork}
fallback={
<>
<ClockIcon class="h-8 w-8 text-muted" aria-hidden="true" />
<h3 class="mt-3 text-sm font-semibold text-base-content">No items in this view</h3>
<p class="mt-1 max-w-md text-xs leading-5 text-muted">
{summary()?.coverageState === 'partial'
? 'The Inbox is empty, but protection context is incomplete. Pulse is not treating that gap as proof of health.'
: 'Refresh the Inbox to check the current evaluation.'}
</p>
</>
}
>
<CheckCircleIcon class="h-9 w-9 text-emerald-500" aria-hidden="true" />
<h3 class="mt-3 text-sm font-semibold text-base-content">Nothing needs you right now</h3>
<p class="mt-1 max-w-md text-xs leading-5 text-muted">
{props.hasQuietWork
? 'Patrol can continue with the current issues under this mode.'
: 'The current operational evaluation has no active items.'}
<Show when={summary()?.evaluatedAt}>
{' Checked '}
{formatRelativeTime(summary()!.evaluatedAt, { compact: true })}.
</Show>
</p>
</Show>
</Show>
</div>
);
@ -695,6 +797,7 @@ function AttentionDetail(props: {
lifecycleError: string;
queuePosition: Accessor<number | undefined>;
queueCount: Accessor<number>;
queueLabel: 'Decision' | 'Handled issue';
canPrevious: Accessor<boolean>;
canNext: Accessor<boolean>;
onPrevious: () => void;
@ -795,23 +898,27 @@ function AttentionDetail(props: {
<button
type="button"
class="inline-flex min-h-11 shrink-0 items-center justify-center gap-2 rounded px-2 text-sm font-medium text-muted hover:bg-surface-hover hover:text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 lg:hidden"
aria-label="Back to attention list"
aria-label={
props.queueLabel === 'Handled issue'
? 'Back to handled issues'
: 'Back to attention list'
}
onClick={props.onClose}
>
<ArrowLeftIcon class="h-4 w-4" aria-hidden="true" />
Back to list
</button>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted">
<Show when={props.queuePosition()} fallback="Decision context">
Decision {props.queuePosition()} of {props.queueCount()}
<Show when={props.queuePosition()} fallback={`${props.queueLabel} context`}>
{props.queueLabel} {props.queuePosition()} of {props.queueCount()}
</Show>
</p>
<div class="hidden items-center gap-1 lg:flex">
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded text-muted hover:bg-surface-hover hover:text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-35"
aria-label="Previous decision"
title="Previous decision"
aria-label={`Previous ${props.queueLabel.toLocaleLowerCase()}`}
title={`Previous ${props.queueLabel.toLocaleLowerCase()}`}
disabled={!props.canPrevious()}
onClick={props.onPrevious}
>
@ -820,8 +927,8 @@ function AttentionDetail(props: {
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded text-muted hover:bg-surface-hover hover:text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-35"
aria-label="Next decision"
title="Next decision"
aria-label={`Next ${props.queueLabel.toLocaleLowerCase()}`}
title={`Next ${props.queueLabel.toLocaleLowerCase()}`}
disabled={!props.canNext()}
onClick={props.onNext}
>
@ -860,7 +967,7 @@ function AttentionDetail(props: {
disabled={!props.canNext()}
onClick={props.onNext}
>
Next decision
Next issue
<ChevronRightIcon class="h-4 w-4" aria-hidden="true" />
</Button>
</div>
@ -1309,8 +1416,9 @@ function AttentionLifecycleControls(props: {
</summary>
<div class="pb-1">
<p>
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.
</p>
<p class="mt-1">
For a permanent change,{' '}

View file

@ -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',