mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-20 14:33:30 +00:00
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:
parent
2eeb643afe
commit
e7cc3f4b67
3 changed files with 227 additions and 74 deletions
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,{' '}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue