feat(patrol): simplify the operator workspace

This commit is contained in:
rcourtman 2026-08-18 10:18:19 +01:00
parent e1427e66f8
commit ed5d983e8e
15 changed files with 440 additions and 385 deletions

View file

@ -7467,7 +7467,7 @@
"status": "target-met",
"completion": {
"state": "complete",
"summary": "Monitor-first Patrol operations now has a decision-first, responsive governed floor: authenticated launch stays on infrastructure monitoring; a compact control toolbar gives way to a daily briefing with current decision, critical, and separately canonical approval posture plus one recommended start; Patrol owns a persistent desktop inbox-and-context workspace and a true mobile master/detail flow with identity-stable review progress, previous/next navigation, automatic advancement after reviewed or temporarily suppressed occurrences, and explicit remaining-work feedback over canonical lifecycle, evidence, protection, availability, approval, action, and verification truth; objective setup and verified receipts remain compact supporting context; calm states stay honest; and Assistant remains contextual explanation for the selected thing rather than the operations front door.",
"summary": "Monitor-first Patrol operations now has a one-task-at-a-time, decision-first governed floor: authenticated launch stays on infrastructure monitoring; a compact secondary status row gives way to mutually exclusive Inbox, Protection, and Activity modes; Inbox defaults to one full-width ranked decision queue without duplicate counters, generic row state, or an empty preview; selection alone creates the persistent desktop inbox-and-context workspace or true mobile master/detail flow with identity-stable review progress, previous/next navigation, automatic advancement after reviewed or temporarily suppressed occurrences, and explicit remaining-work feedback over canonical lifecycle, evidence, protection, availability, approval, action, and verification truth; objectives, verified receipts, approvals, and records remain deliberately accessible without competing on the same canvas; calm states stay honest; and Assistant remains contextual explanation for the selected thing rather than the operations front door.",
"tracking": []
},
"blockers": [],

View file

@ -2519,6 +2519,12 @@ a new API state machine, queue contract, or verification-accounting field.
attention projection by severity, then actionable review posture, then
observation recency, but it must preserve every API-owned lifecycle,
evidence, protection, action, and verification fact in selected detail.
The first-party `Inbox`, `Protection`, and `Activity` modes are a mutually
exclusive presentation boundary, not API query modes: Inbox consumes the
canonical attention projection, Protection consumes retained objectives,
and Activity consumes verified receipts plus existing action/finding/run
records. Switching panels must not rewrite membership, merge counts, invent
lifecycle state, or refetch one dataset as though it represented another.
Secondary receipt responses that are absent or incomplete may degrade to an
unavailable or empty supporting panel; they must not unmount the current
attention workbench or convert missing receipts into successful work

View file

@ -772,16 +772,17 @@ assessment semantics, but they must keep those semantics in their governed
presentation helpers and render them inside the shared neutral Pulse surface
language rather than introducing page-local verdict bands or nested cards.
The Patrol operator home composes the shared `PageHeader`, `Button`, `Toggle`,
`MetadataBadge`, native disclosure, dialog, and neutral bordered-surface
patterns. Patrol owns the meaning and order of background posture, genuine
decisions, objectives, verified receipts, and secondary operational records; the
shared primitive boundary owns keyboard focus, touch targets, responsive
stacking, and visual consistency. Plan-locked paid-mode discovery is not a
`MetadataBadge`, native disclosure, dialog, tab, and neutral bordered-surface
patterns. Patrol owns the meaning of background posture and the mutually
exclusive `Inbox`, `Protection`, and `Activity` modes; the shared primitive
boundary owns visible selection, Arrow/Home/End keyboard movement, roving tab
focus, touch targets, responsive stacking, and visual consistency. Only the
selected mode's panel is rendered. Plan-locked paid-mode discovery is not a
daily-page primitive and must not be added beside the effective mode.
Current API-owned decisions must follow the compact posture header and precede
objective configuration at every viewport; an empty objective state must not
push current work below the first useful screen. The compact `Verified outcomes`
list consumes server-authored Patrol work
Current API-owned decisions must follow the compact posture row in the default
Inbox at every viewport. Objective configuration and verified/history surfaces
belong to their explicit modes rather than following the queue on the same
canvas. The compact `Verified outcomes` list consumes server-authored Patrol work
receipts rather than filtering generic history in the browser. It may format
the canonical capability name and verification time with shared presentation
helpers, and it reuses the action resource presenter for durable resources that

View file

@ -75,16 +75,16 @@ sources, and retains the note as operator context.
### Quiet operator home
The first-party Patrol page is an outcome and exception surface, not a mirror of
the internal lifecycle. Its primary order is the effective background posture,
work that genuinely needs the operator, the outcomes Patrol is looking after,
and concise recent receipts. Active work must outrank objective configuration
on the daily-use route, especially on a narrow viewport; an empty objectives
surface cannot push current decisions below the first useful screen.
Investigation internals, lifecycle filters, raw findings, checks, and run
history remain available as secondary operational records without competing
with those four questions. Action review/audit and finding/run records use one
subordinate `Review and history` handoff area so the page does not present
several competing history concepts.
the internal lifecycle. After the effective background posture it exposes three
mutually exclusive operator modes: `Inbox`, `Protection`, and `Activity`.
`Inbox` is the default and mounts only genuine operator decisions;
`Protection` owns retained objectives; `Activity` owns verified receipts plus
the subordinate action-audit and finding/run-record handoffs. Active decisions,
objective configuration, receipts, and operational records must not all render
at once. Investigation internals, lifecycle filters, raw findings, checks, and
run history remain deliberately available without competing with the daily
decision queue. The mode selector is keyboard navigable and retains the stable
Patrol route rather than inventing API filters or lifecycle states.
`patrolHomePresentation.ts` owns the deterministic projection from effective
autonomy and typed attention evidence into `needs user` versus `can continue`
@ -1204,15 +1204,16 @@ The durable decision and proof record is
## Current State
The Patrol 2.0 frontend now uses a decision-first information hierarchy. The
compact command header owns enabled posture, effective mode, last/next check
context, manual check, settings, and the secondary mode disclosure. The typed
attention workbench is the first substantial page surface on desktop and
mobile. Protected objectives and proof-constrained verified outcomes follow as
supporting continuity surfaces; empty objectives and empty receipts stay
compact instead of consuming the prime viewport. Universal governed actions
and Patrol-specific finding/run records are exposed together through the
subordinate `Review and history` handoff area.
The Patrol 2.0 frontend now uses a one-task-at-a-time, decision-first
information hierarchy. The compact, borderless command row owns enabled
posture, effective mode, last/next check context, manual check, settings, and
the secondary mode disclosure without presenting a second primary CTA card.
The typed attention workbench is the only substantial default page surface on
desktop and mobile. `Protection` deliberately swaps in retained objectives;
`Activity` swaps in proof-constrained verified outcomes and the subordinate
`Review and history` handoff for universal governed actions and Patrol-specific
finding/run records. Empty continuity surfaces therefore never share the prime
viewport with current decisions.
The page introduction states the operator job—find the decision, choose the
next step, and retain a verified record—rather than repeating the command
@ -1224,17 +1225,18 @@ does not merge governed action records into the attention list or invent an
attention count; the queue and approval inbox retain their separate canonical
projections.
The default loaded state is a daily briefing rather than an unframed list. It
states how many current decisions need the operator, shows the current loaded
critical-decision and separately canonical approval counts, explains the queue
ordering, and offers one `Start review` action for the highest-priority item.
On wide layouts the inbox and a recommended first-decision preview occupy a
persistent two-pane workspace before selection; selection replaces the preview
with the full governed context without hiding the inbox. On narrow layouts the
selected context intentionally replaces the list until `Back to list`, making
Patrol a true master/detail workflow instead of stacking the detail above a
second full queue. The command header remains compact so the briefing and first
decision enter the initial viewport.
The default loaded state is a full-width decision inbox with one headline that
states how many current decisions need the operator, one short explanation of
the ordering, one `Start review` action for the highest-priority item, the
canonical pending-approval handoff when needed, and refresh. It must not repeat
the same facts as a metric strip, generic row state, generic `Review` label, or
empty recommended-item preview. A two-pane workspace appears only after the
operator selects or deep-links a decision; wide layouts retain the inbox beside
the full governed context. On narrow layouts the selected context intentionally
replaces the list until `Back to list`, making Patrol a true master/detail
workflow instead of stacking the detail above a second full queue. The command
row remains compact and visually secondary so the inbox and first decisions
enter the initial viewport.
Selecting the first item starts a bounded review session over the loaded
decision order. The selected context shows its current position and exposes
@ -1249,10 +1251,12 @@ that the occurrence will return at the chosen time. Clearing the final item
returns to the calm inbox with an honest completion announcement.
The attention queue orders genuine operator decisions by severity, actionable
approval/review state, and latest observation time. Rows expose the severity
word, lifecycle state, bounded consequence, affected resource, decision label,
and freshness without repeating full mode-policy prose on every item. Selecting
an item opens one responsive decision context: the current summary, affected
approval/review state, and latest observation time. Collapsed rows expose the
severity word, bounded consequence, affected resource, and freshness; only
exceptional approval-needed or verification-result work adds another label.
Lifecycle state remains selected-detail truth instead of a repeated `Open`
badge, and ordinary queue membership is not restated as `Review` on every row.
Selecting an item opens one responsive decision context: the current summary, affected
resource, impact and recommended next step precede governed action review;
occurrence acknowledgement/suppression, evidence, protection posture, timeline,
resource navigation, and contextual Assistant explanation remain available

View file

@ -1,26 +1,22 @@
{
"version": 1,
"base_sha": "0da7092991898ca4b125f75cd7dacf9d492b7126",
"verified_at": "2026-08-18T10:05:00Z",
"base_sha": "e1427e66f8614d0fea90d62d7f785f93595fdae1",
"verified_at": "2026-08-18T09:24:31Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/useAppRuntimeState.ts"
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx",
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx",
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx"
],
"content_sha256": {
"frontend-modern/src/useAppRuntimeState.ts": "311d1b7c6a97b94830311650b4efe5ee5cf57215c903e9835be70441a62836dd"
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx": "770c7eaecf23026916fa48198fe5220be6db076fbd30a7351e250d2cbeec9fb4",
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx": "f147df3ec75a27fb5d06b6cf9e8f487b02d93af1630c8d7fb0fd3688b264a3dc",
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "4a3d400d59c42a48b0488ef0a4b0026f39326e1cbfc44d19528198bfd84030e1"
},
"routes": [
"/login",
"/",
"/proxmox/overview",
"/docker/overview",
"/kubernetes/overview",
"/truenas/overview",
"/vsphere/overview"
],
"routes": ["/patrol"],
"viewports": [
{
"width": 1440,
"width": 1280,
"height": 900
},
{
@ -29,25 +25,18 @@
}
],
"states": [
"signed-out login form",
"authenticated shell against a backend that reports the platformAdmission facet",
"navigation resolved with all six primary platform tabs",
"backend stopped mid-session, shell showing a disconnected socket",
"backend restored and the socket reconnected",
"Docker, Kubernetes, TrueNAS and vSphere platform pages populated",
"shell at 390x844 with the Proxmox table rendered"
"Default live Inbox with 12 ranked decisions, one critical item, and one governed approval waiting; Protection, verified outcomes, and history absent from the canvas",
"Protection mode with retained-objective setup only and no decision queue or activity surface",
"Activity mode with verified outcomes and the subordinate actions, findings, and run-record handoffs",
"Desktop selected-decision split with persistent queue, current position, previous/next navigation, and governed lifecycle, evidence, protection, resource, and Assistant context",
"390x844 selected-decision master/detail state with the queue hidden, Back to list, queue position, and touch-sized navigation",
"Current-coverage calm state, transport failure state, and attached availability failure without synthetic health"
],
"interactions": [
"signed in and confirmed admission is read once from a one-resource request",
"idled 20 seconds with live runtime state and confirmed no further admission requests, so the steady state polls nothing",
"stopped the backend mid-session, watched the shell report a disconnected socket, restarted it, and confirmed admission was refetched once on reconnect",
"confirmed navigation still listed every platform tab after the reconnect cycle",
"switched to the Docker tab and confirmed 80 rows",
"switched to the Kubernetes tab and confirmed 180 rows",
"switched to the TrueNAS tab and confirmed 3 rows",
"switched to the vSphere tab and confirmed 32 rows",
"returned to the Proxmox tab and confirmed 79 rows",
"resized to 390x844 and confirmed the Proxmox table still renders 79 rows",
"confirmed zero console or page errors across the pass"
"Used the live in-app Browser to inspect the final Inbox, Protection, Activity, and selected-decision split against current backend data",
"Exercised the workspace tabs and confirmed only the selected panel is present while stable Patrol route and API truth are retained",
"Verified automatic advancement after Mark reviewed, bounded suppression feedback, inbox focus restoration, deep-link reload, and no horizontal overflow",
"Passed the complete 18-scenario attention-workbench matrix across Chromium, mobile Chrome, and mobile Safari; the two corrected exact-name checks were rerun across all three engines",
"Passed all eight affected Assistant, monitor-first, and attached-availability journeys in Chromium"
]
}
}

View file

@ -127,13 +127,14 @@ export function PatrolAttentionWorkbench(
props.autonomyLocked ? 'monitor' : (props.autonomyLevel ?? 'monitor'),
);
const autonomyExperience = createMemo(() => PATROL_AUTONOMY_EXPERIENCE[effectiveAutonomyLevel()]);
const attention = createMemo(() =>
partitionPatrolAttention(
patrolAttentionStore.items(),
const attention = createMemo(() => {
const items = patrolAttentionStore.items();
return partitionPatrolAttention(
Array.isArray(items) ? items : [],
props.autonomyLevel ?? 'monitor',
props.autonomyLocked ?? false,
),
);
);
});
const sortedDecisions = createMemo(() => sortPatrolAttentionDecisions(attention().needsUser));
const orderedDecisions = createMemo(() => {
const current = sortedDecisions();
@ -165,9 +166,6 @@ export function PatrolAttentionWorkbench(
const index = selectedDecisionIndex();
return index >= 0 ? orderedDecisions()[index + 1] : undefined;
});
const criticalDecisionCount = createMemo(
() => attention().needsUser.filter((decision) => decision.item.severity === 'critical').length,
);
const briefingHeadline = createMemo(() => {
if (patrolAttentionStore.loading() && !summary()) return 'Building your current briefing';
const count = attention().needsUser.length;
@ -310,105 +308,58 @@ export function PatrolAttentionWorkbench(
}
});
const decisionCountLabel = createMemo(() => {
const count = attention().needsUser.length;
return `${count} ${count === 1 ? 'item requires' : 'items require'} review`;
});
return (
<section
aria-labelledby="patrol-attention-heading"
aria-label="Patrol decision inbox"
class="overflow-hidden rounded-xl border border-border bg-surface shadow-sm"
>
<div class="border-b border-border bg-blue-50/50 px-4 py-5 dark:bg-blue-950/20 sm:px-6 sm:py-6">
<div class="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<div class="border-b border-border px-4 py-4 sm:px-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<p class="text-[11px] font-semibold uppercase tracking-[0.16em] text-blue-700 dark:text-blue-300">
Today's Patrol briefing
</p>
<h2
id="patrol-attention-heading"
class="mt-2 text-base font-semibold text-base-content"
>
Needs your attention
</h2>
<p class="mt-1 text-2xl font-semibold tracking-tight text-base-content sm:text-3xl">
<h2 class="text-xl font-semibold tracking-tight text-base-content sm:text-2xl">
{briefingHeadline()}
</p>
<p class="mt-2 max-w-3xl text-sm leading-6 text-muted">
</h2>
<p class="mt-1 text-sm leading-5 text-muted">
{attention().needsUser.length > 0
? 'Patrol has already ordered the queue by severity, actionability, and the freshest evidence.'
? 'Highest priority first, based on severity, actionability, and current evidence.'
: autonomyExperience().needsYouDescription}
</p>
</div>
<div class="w-full lg:w-[32rem]">
<div class="flex flex-wrap items-center gap-2 lg:justify-end">
<Show when={!selectedItemId() && highestPriorityDecision()}>
{(decision) => (
<Button
variant="primary"
size="sm"
class="gap-1.5"
onClick={() => selectItem(decision().item.id)}
>
Start review
<ChevronRightIcon class="h-4 w-4" aria-hidden="true" />
</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" />
Review {props.pendingActionCount}{' '}
{props.pendingActionCount === 1 ? 'approval' : 'approvals'}
</ButtonLink>
</Show>
<Button
variant="ghost"
size="sm"
class="gap-1.5"
onClick={() => void loadCurrentFilter()}
disabled={patrolAttentionStore.loading()}
aria-label="Refresh Patrol attention"
>
<RefreshIcon
class={`h-4 w-4 ${patrolAttentionStore.loading() ? 'motion-safe:animate-spin' : ''}`}
aria-hidden="true"
/>
Refresh
</Button>
</div>
<Show when={summary()}>
<div
class="mt-4 grid grid-cols-3 overflow-hidden rounded-lg border border-border bg-surface/80 shadow-sm"
aria-label={decisionCountLabel()}
>
<div class="px-3 py-3 sm:px-4">
<span class="block text-xl font-semibold text-red-700 dark:text-red-300">
{criticalDecisionCount()}
</span>
<span class="mt-0.5 block text-[11px] font-medium uppercase tracking-wide text-muted">
Critical
</span>
</div>
<div class="border-l border-border px-3 py-3 sm:px-4">
<span class="block text-xl font-semibold text-base-content">
{attention().needsUser.length}
</span>
<span class="mt-0.5 block text-[11px] font-medium uppercase tracking-wide text-muted">
Decisions
</span>
</div>
<div class="border-l border-border px-3 py-3 sm:px-4">
<span class="block text-xl font-semibold text-amber-700 dark:text-amber-300">
{props.pendingActionCount ?? 0}
</span>
<span class="mt-0.5 block text-[11px] font-medium uppercase tracking-wide text-muted">
Approvals
</span>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 sm:justify-end">
<Show when={!selectedItemId() && highestPriorityDecision()}>
{(decision) => (
<Button
variant="primary"
size="sm"
class="gap-1.5"
onClick={() => selectItem(decision().item.id)}
>
Start review
<ChevronRightIcon class="h-4 w-4" aria-hidden="true" />
</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" />
Review {props.pendingActionCount}{' '}
{props.pendingActionCount === 1 ? 'approval' : 'approvals'}
</ButtonLink>
</Show>
<Button
variant="ghost"
size="sm"
class="gap-1.5"
onClick={() => void loadCurrentFilter()}
disabled={patrolAttentionStore.loading()}
aria-label="Refresh Patrol attention"
>
<RefreshIcon
class={`h-4 w-4 ${patrolAttentionStore.loading() ? 'motion-safe:animate-spin' : ''}`}
aria-hidden="true"
/>
Refresh
</Button>
</div>
</div>
<Show when={attention().quiet.length > 0}>
@ -433,22 +384,11 @@ export function PatrolAttentionWorkbench(
</Show>
<div
class={`grid min-w-0 ${attention().needsUser.length > 0 ? 'lg:grid-cols-[minmax(20rem,0.78fr)_minmax(0,1.22fr)]' : ''}`}
class={`grid min-w-0 ${selectedItemId() ? 'lg:grid-cols-[minmax(20rem,0.78fr)_minmax(0,1.22fr)]' : ''}`}
>
<div
class={`min-w-0 ${selectedItemId() ? 'hidden lg:block' : ''} ${attention().needsUser.length > 0 ? 'lg:max-h-[52rem] lg:overflow-y-auto lg:border-r lg:border-border' : ''}`}
class={`min-w-0 ${selectedItemId() ? 'hidden lg:block lg:max-h-[52rem] lg:overflow-y-auto lg:border-r lg:border-border' : ''}`}
>
<Show when={attention().needsUser.length > 0}>
<div class="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-border bg-surface/95 px-4 py-3 backdrop-blur sm:px-5">
<div>
<h3 class="text-sm font-semibold text-base-content">Decision inbox</h3>
<p class="mt-0.5 text-[11px] text-muted">Highest priority first</p>
</div>
<MetadataBadge tone="neutral" size="xs" shape="rounded">
{attention().needsUser.length} open
</MetadataBadge>
</div>
</Show>
<AttentionList
decisions={visibleDecisions()}
selectedItemId={selectedItemId()}
@ -469,42 +409,7 @@ export function PatrolAttentionWorkbench(
</div>
</Show>
</div>
<Show
when={selectedItemId()}
fallback={
<Show when={highestPriorityDecision()}>
{(decision) => (
<div class="hidden min-h-[30rem] flex-col items-start justify-start bg-surface-alt/20 px-8 py-8 text-left lg:flex">
<span class="inline-flex h-12 w-12 items-center justify-center rounded-xl border border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-300">
<SparklesIcon class="h-5 w-5" aria-hidden="true" />
</span>
<p class="mt-5 text-[11px] font-semibold uppercase tracking-[0.14em] text-muted">
Start here
</p>
<h3 class="mt-2 max-w-lg text-lg font-semibold text-base-content">
{decision().item.title}
</h3>
<p class="mt-2 max-w-lg text-sm leading-6 text-muted">
{decision().item.plainLanguageSummary}
</p>
<Button
variant="primary"
size="md"
class="mt-5 gap-2"
onClick={() => selectItem(decision().item.id)}
>
Review this decision
<ChevronRightIcon class="h-4 w-4" aria-hidden="true" />
</Button>
<p class="mt-4 max-w-md text-xs leading-5 text-muted">
Patrol chose this from severity, available action, and evidence recency. It will
not make a change from this screen without the governed action flow.
</p>
</div>
)}
</Show>
}
>
<Show when={selectedItemId()}>
<div
ref={detailPanel}
tabindex="-1"
@ -576,6 +481,11 @@ function AttentionList(props: {
itemButtons: Map<string, HTMLButtonElement>;
onSelect: (itemId: string) => void;
}) {
const hasQuietWork = () => {
const items = patrolAttentionStore.items();
return Array.isArray(items) && items.length > 0;
};
return (
<div aria-live="polite">
<Show when={patrolAttentionStore.error()}>
@ -604,7 +514,7 @@ function AttentionList(props: {
>
<Show
when={props.decisions.length > 0}
fallback={<AttentionEmptyState hasQuietWork={patrolAttentionStore.items().length > 0} />}
fallback={<AttentionEmptyState hasQuietWork={hasQuietWork()} />}
>
<ul class="divide-y divide-border" aria-label="Patrol attention items">
<For each={props.decisions}>
@ -621,7 +531,7 @@ function AttentionList(props: {
if (item.verificationState === 'failed' || item.verificationState === 'unknown') {
return 'Verify result';
}
return 'Review';
return undefined;
};
return (
<li>
@ -646,18 +556,21 @@ function AttentionList(props: {
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-base-content">
{item.title}
</span>
<StateBadge item={item} />
</div>
<p class="mt-1 line-clamp-2 text-xs leading-5 text-muted">
{item.plainLanguageSummary}
</p>
<div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
<span
class="font-semibold text-amber-700 dark:text-amber-300"
title={decision.reason}
>
{decisionLabel()}
</span>
<Show when={decisionLabel()}>
{(label) => (
<span
class="font-semibold text-amber-700 dark:text-amber-300"
title={decision.reason}
>
{label()}
</span>
)}
</Show>
<span class="truncate font-medium text-base-content">
{item.subjectResourceName}
</span>

View file

@ -301,8 +301,8 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
class="relative z-[200] mb-3"
/>
<section class="overflow-hidden rounded-lg border border-border bg-surface shadow-sm">
<div class="flex flex-col gap-3 px-4 py-3 sm:px-5 lg:flex-row lg:items-center lg:justify-between">
<section class="border-y border-border">
<div class="flex flex-col gap-3 px-1 py-2 sm:px-2 lg:flex-row lg:items-center lg:justify-between">
<div class="flex min-w-0 items-start gap-3">
<div class="pt-0.5">
<TogglePrimitive
@ -359,14 +359,14 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
<Show when={!state.shouldShowPatrolSetupOnly()}>
<div class="grid w-full shrink-0 grid-cols-2 gap-2 lg:flex lg:w-auto lg:flex-wrap lg:items-center">
{renderRunControl(
'flex min-h-11 items-center justify-center gap-2 rounded-md border border-blue-600 bg-blue-600 px-3 py-1.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-blue-700 disabled:border-border disabled:bg-surface-alt disabled:text-muted sm:min-h-0',
'flex min-h-11 items-center justify-center gap-2 rounded-md border border-border bg-surface px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover disabled:bg-surface-alt disabled:text-muted sm:min-h-0',
)}
<Show when={!runBlockedByProviderSetup()}>
<A
href={settingsTabPath('system-ai-patrol')}
aria-label="Open Patrol settings"
title="Open Patrol settings"
class="flex min-h-11 items-center justify-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm font-medium text-base-content shadow-sm transition-colors hover:bg-surface-alt sm:min-h-0"
class="flex min-h-11 items-center justify-center gap-2 rounded-md border border-transparent px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:bg-surface-hover hover:text-base-content sm:min-h-0"
>
<SettingsIcon class="h-4 w-4" />
Settings
@ -376,8 +376,8 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
</Show>
</div>
<details id={PATROL_CONTROL_ANCHOR} class="border-t border-border-subtle px-4 py-2 sm:px-5">
<summary class="min-h-11 cursor-pointer text-sm font-medium text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 sm:min-h-0">
<details id={PATROL_CONTROL_ANCHOR} class="border-t border-border-subtle px-1 py-1 sm:px-2">
<summary class="min-h-11 cursor-pointer text-xs font-medium text-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 sm:min-h-0">
Mode and automation
</summary>
<div class="pt-3">

View file

@ -14,12 +14,38 @@ import { PatrolAttentionWorkbench } from './PatrolAttentionWorkbench';
import { PatrolObjectivesPanel } from './PatrolObjectivesPanel';
import { PatrolRecentWorkPanel } from './PatrolRecentWorkPanel';
type PatrolWorkspaceView = 'inbox' | 'protection' | 'activity';
const PATROL_WORKSPACE_VIEWS: readonly PatrolWorkspaceView[] = ['inbox', 'protection', 'activity'];
export function PatrolIntelligenceSurface() {
const state = usePatrolIntelligenceState();
const [activeView, setActiveView] = createSignal<PatrolWorkspaceView>('inbox');
const [findingsOpen, setFindingsOpen] = createSignal(false);
const workspaceTabs: Partial<Record<PatrolWorkspaceView, HTMLButtonElement>> = {};
let findingsPanel: HTMLDetailsElement | undefined;
const openWorkCount = () => aiIntelligenceStore.patrolOpenWorkCount;
const activateView = (view: PatrolWorkspaceView, focus = false) => {
setActiveView(view);
if (focus) queueMicrotask(() => workspaceTabs[view]?.focus());
};
const handleWorkspaceKeyDown = (event: KeyboardEvent, currentView: PatrolWorkspaceView) => {
const currentIndex = PATROL_WORKSPACE_VIEWS.indexOf(currentView);
const requestedIndex =
event.key === 'ArrowRight'
? (currentIndex + 1) % PATROL_WORKSPACE_VIEWS.length
: event.key === 'ArrowLeft'
? (currentIndex - 1 + PATROL_WORKSPACE_VIEWS.length) % PATROL_WORKSPACE_VIEWS.length
: event.key === 'Home'
? 0
: event.key === 'End'
? PATROL_WORKSPACE_VIEWS.length - 1
: -1;
if (requestedIndex < 0) return;
event.preventDefault();
activateView(PATROL_WORKSPACE_VIEWS[requestedIndex], true);
};
const openFindings = () => {
activateView('activity');
setFindingsOpen(true);
queueMicrotask(() => {
findingsPanel?.scrollIntoView?.({ block: 'start' });
@ -31,120 +57,171 @@ export function PatrolIntelligenceSurface() {
<div class="space-y-4 lg:space-y-5">
<PatrolIntelligenceHeader state={state} />
<PatrolIntelligenceBanners state={state} />
<PatrolAttentionWorkbench
autonomyLevel={state.autonomyLevel()}
autonomyLocked={state.autoFixLocked()}
pendingActionCount={actionInboxStore.pendingActionCount}
onOpenFindings={openFindings}
/>
<div class="grid min-w-0 gap-5 xl:grid-cols-[minmax(0,1.15fr)_minmax(20rem,0.85fr)]">
<PatrolObjectivesPanel />
<PatrolRecentWorkPanel />
<div role="tablist" aria-label="Patrol workspace" class="flex gap-1 border-b border-border">
{PATROL_WORKSPACE_VIEWS.map((view) => (
<button
ref={(element) => {
workspaceTabs[view] = element;
}}
type="button"
id={`patrol-${view}-tab`}
role="tab"
aria-selected={activeView() === view}
aria-controls={`patrol-${view}-panel`}
tabindex={activeView() === view ? 0 : -1}
class={`relative min-h-11 rounded-t-md px-4 py-2 text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 ${
activeView() === view
? 'bg-surface text-base-content after:absolute after:inset-x-2 after:-bottom-px after:h-0.5 after:bg-blue-600'
: 'text-muted hover:bg-surface-hover hover:text-base-content'
}`}
onClick={() => activateView(view)}
onKeyDown={(event) => handleWorkspaceKeyDown(event, view)}
>
{view === 'inbox' ? 'Inbox' : view === 'protection' ? 'Protection' : 'Activity'}
</button>
))}
</div>
<section
aria-labelledby="patrol-continuity-title"
class="rounded-lg border border-border bg-surface"
>
<div class="border-b border-border px-4 py-4 sm:px-5">
<h2 id="patrol-continuity-title" class="text-base font-semibold text-base-content">
Review and history
</h2>
<p class="mt-1 max-w-3xl text-sm leading-5 text-muted">
Follow pending decisions, audit completed operations, or inspect Patrol's underlying
records.
</p>
</div>
<div class="grid divide-y divide-border sm:grid-cols-2 sm:divide-x sm:divide-y-0">
<ButtonLink
href="/actions"
variant="ghost"
size="md"
class="group h-auto min-h-20 w-full justify-between rounded-none px-4 py-3 text-left sm:px-5"
<Show when={activeView() === 'inbox'}>
<div id="patrol-inbox-panel" role="tabpanel" aria-labelledby="patrol-inbox-tab">
<Show
when={!state.shouldShowPatrolSetupOnly()}
fallback={
<div
class={`rounded-lg border border-border bg-surface p-4 sm:p-5 ${!state.patrolEnabledLocal() ? 'pointer-events-none opacity-50' : ''}`}
>
<PatrolIntelligenceWorkspace state={state} />
</div>
}
>
<span class="flex min-w-0 items-start gap-3">
<span class="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-surface-alt text-muted">
<ClipboardCheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
<span class="min-w-0">
<span class="flex flex-wrap items-center gap-2 text-sm font-semibold text-base-content">
Actions and approvals
<Show when={actionInboxStore.pendingActionCount > 0}>
<MetadataBadge
tone="warning"
size="xs"
shape="rounded"
aria-label={`${actionInboxStore.pendingActionCount} ${actionInboxStore.pendingActionCount === 1 ? 'operation needs' : 'operations need'} review`}
>
{actionInboxStore.pendingActionCount} waiting
</MetadataBadge>
</Show>
</span>
<span class="mt-1 block text-xs font-normal leading-5 text-muted">
Review governed actions and their complete audit trail.
</span>
</span>
</span>
<ArrowRightIcon
class="h-4 w-4 shrink-0 text-muted transition-transform group-hover:translate-x-0.5"
aria-hidden="true"
<PatrolAttentionWorkbench
autonomyLevel={state.autonomyLevel()}
autonomyLocked={state.autoFixLocked()}
pendingActionCount={actionInboxStore.pendingActionCount}
onOpenFindings={openFindings}
/>
</ButtonLink>
<button
type="button"
class="group flex min-h-20 w-full items-center justify-between gap-3 px-4 py-3 text-left hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 sm:px-5"
aria-expanded={findingsOpen()}
aria-controls="patrol-operational-records"
onClick={() => setFindingsOpen((open) => !open)}
>
<span class="flex min-w-0 items-start gap-3">
<span class="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-surface-alt text-muted">
<HistoryIcon class="h-5 w-5" aria-hidden="true" />
</span>
<span class="min-w-0">
<span class="flex flex-wrap items-center gap-2 text-sm font-semibold text-base-content">
Findings and run records
<Show when={openWorkCount() > 0}>
<MetadataBadge
tone="info"
size="xs"
shape="rounded"
aria-label={`${openWorkCount()} open patrol ${openWorkCount() === 1 ? 'finding' : 'findings'}`}
>
{openWorkCount()} open
</MetadataBadge>
</Show>
</span>
<span class="mt-1 block text-xs font-normal leading-5 text-muted">
Inspect finding detail, check results, and historical runs.
</span>
</span>
</span>
<ArrowRightIcon
class={`h-4 w-4 shrink-0 text-muted transition-transform ${findingsOpen() ? 'rotate-90' : ''}`}
aria-hidden="true"
/>
</button>
</Show>
</div>
</section>
</Show>
<details
ref={findingsPanel}
id="patrol-operational-records"
tabindex="-1"
class="rounded-lg border border-border bg-surface"
open={findingsOpen()}
onToggle={(event) => setFindingsOpen(event.currentTarget.open)}
>
<summary class="sr-only">Findings and run records</summary>
<Show when={activeView() === 'protection'}>
<div id="patrol-protection-panel" role="tabpanel" aria-labelledby="patrol-protection-tab">
<PatrolObjectivesPanel />
</div>
</Show>
<Show when={activeView() === 'activity'}>
<div
class={`space-y-4 border-t border-border p-4 sm:p-5 ${!state.patrolEnabledLocal() ? 'opacity-50 pointer-events-none' : ''}`}
id="patrol-activity-panel"
role="tabpanel"
aria-labelledby="patrol-activity-tab"
class="space-y-4 lg:space-y-5"
>
<PatrolIntelligenceWorkspace state={state} />
<PatrolRecentWorkPanel />
<section
aria-labelledby="patrol-continuity-title"
class="rounded-lg border border-border bg-surface"
>
<div class="border-b border-border px-4 py-4 sm:px-5">
<h2 id="patrol-continuity-title" class="text-base font-semibold text-base-content">
Review and history
</h2>
<p class="mt-1 max-w-3xl text-sm leading-5 text-muted">
Audit governed operations or inspect Patrol's underlying records.
</p>
</div>
<div class="grid divide-y divide-border sm:grid-cols-2 sm:divide-x sm:divide-y-0">
<ButtonLink
href="/actions"
variant="ghost"
size="md"
class="group h-auto min-h-20 w-full justify-between rounded-none px-4 py-3 text-left sm:px-5"
>
<span class="flex min-w-0 items-start gap-3">
<span class="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-surface-alt text-muted">
<ClipboardCheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
<span class="min-w-0">
<span class="flex flex-wrap items-center gap-2 text-sm font-semibold text-base-content">
Actions and approvals
<Show when={actionInboxStore.pendingActionCount > 0}>
<MetadataBadge
tone="warning"
size="xs"
shape="rounded"
aria-label={`${actionInboxStore.pendingActionCount} ${actionInboxStore.pendingActionCount === 1 ? 'operation needs' : 'operations need'} review`}
>
{actionInboxStore.pendingActionCount} waiting
</MetadataBadge>
</Show>
</span>
<span class="mt-1 block text-xs font-normal leading-5 text-muted">
Review governed actions and their complete audit trail.
</span>
</span>
</span>
<ArrowRightIcon
class="h-4 w-4 shrink-0 text-muted transition-transform group-hover:translate-x-0.5"
aria-hidden="true"
/>
</ButtonLink>
<button
type="button"
class="group flex min-h-20 w-full items-center justify-between gap-3 px-4 py-3 text-left hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 sm:px-5"
aria-expanded={findingsOpen()}
aria-controls="patrol-operational-records"
onClick={() => setFindingsOpen((open) => !open)}
>
<span class="flex min-w-0 items-start gap-3">
<span class="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-surface-alt text-muted">
<HistoryIcon class="h-5 w-5" aria-hidden="true" />
</span>
<span class="min-w-0">
<span class="flex flex-wrap items-center gap-2 text-sm font-semibold text-base-content">
Findings and run records
<Show when={openWorkCount() > 0}>
<MetadataBadge
tone="info"
size="xs"
shape="rounded"
aria-label={`${openWorkCount()} open patrol ${openWorkCount() === 1 ? 'finding' : 'findings'}`}
>
{openWorkCount()} open
</MetadataBadge>
</Show>
</span>
<span class="mt-1 block text-xs font-normal leading-5 text-muted">
Inspect finding detail, check results, and historical runs.
</span>
</span>
</span>
<ArrowRightIcon
class={`h-4 w-4 shrink-0 text-muted transition-transform ${findingsOpen() ? 'rotate-90' : ''}`}
aria-hidden="true"
/>
</button>
</div>
</section>
<details
ref={findingsPanel}
id="patrol-operational-records"
tabindex="-1"
class="rounded-lg border border-border bg-surface"
open={findingsOpen()}
onToggle={(event) => setFindingsOpen(event.currentTarget.open)}
>
<summary class="sr-only">Findings and run records</summary>
<div
class={`space-y-4 border-t border-border p-4 sm:p-5 ${!state.patrolEnabledLocal() ? 'opacity-50 pointer-events-none' : ''}`}
>
<PatrolIntelligenceWorkspace state={state} />
</div>
</details>
</div>
</details>
</Show>
</div>
);
}

View file

@ -230,16 +230,17 @@ describe('PatrolAttentionWorkbench', () => {
renderWorkbench();
expect(
await screen.findByRole('heading', { name: 'Needs your attention' }),
await screen.findByRole('heading', { name: '1 decision needs you' }),
).toBeInTheDocument();
expect(screen.getByText('1 decision needs you')).toBeInTheDocument();
expect(screen.getByText("Today's Patrol briefing")).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Decision inbox' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Start review' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Review this decision' })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Open Disk pressure on Database VM' }),
).toBeInTheDocument();
expect(screen.queryByText("Today's Patrol briefing")).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Decision inbox' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Review this decision' })).not.toBeInTheDocument();
expect(screen.queryByText('Open', { exact: true })).not.toBeInTheDocument();
expect(screen.queryByText('Review', { exact: true })).not.toBeInTheDocument();
expect(screen.queryByRole('group', { name: 'Attention state' })).not.toBeInTheDocument();
expect(screen.queryByRole('combobox', { name: 'Attention state' })).not.toBeInTheDocument();
expect(apiMocks.getList).toHaveBeenCalledWith('active');

View file

@ -103,10 +103,10 @@ describe('PatrolIntelligenceHeader', () => {
it('keeps primary Patrol actions touch-sized on phones without inflating desktop chrome', () => {
expect(headerSource).toContain(
'flex min-h-11 items-center justify-center gap-2 rounded-md border border-blue-600',
'flex min-h-11 items-center justify-center gap-2 rounded-md border border-border',
);
expect(headerSource).toContain(
'flex min-h-11 items-center justify-center gap-2 rounded-md border border-border',
'flex min-h-11 items-center justify-center gap-2 rounded-md border border-transparent',
);
expect(headerSource).toContain('sm:min-h-0');
});

View file

@ -535,6 +535,13 @@ const defaultOperationsLoopStatus = (overrides: Record<string, unknown> = {}) =>
...overrides,
});
async function openPatrolActivityMode() {
await waitFor(() => {
expect(screen.getByRole('tab', { name: 'Activity' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('tab', { name: 'Activity' }));
}
describe('AIIntelligence entitlement gating', () => {
it('keeps Patrol page data sync bounded without making it a primary action', () => {
expect(patrolIntelligenceStateSource).toContain('PATROL_REFRESH_TIMEOUT_MS');
@ -729,7 +736,9 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.queryByRole('link', { name: 'View plans' })).not.toBeInTheDocument();
expect(patrolControlAnchor).toContainElement(operationsLoopAnchor);
expect(screen.queryByTestId('patrol-current-work')).not.toBeInTheDocument();
expect(screen.getByText('Current Patrol issues appear here.')).toBeInTheDocument();
expect(screen.queryByText('Current Patrol issues appear here.')).not.toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Inbox' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('region', { name: 'Patrol decision inbox' })).toBeInTheDocument();
expect(
screen.queryByText('Issues Patrol found. Infrastructure stays unchanged.'),
).not.toBeInTheDocument();
@ -887,7 +896,7 @@ describe('AIIntelligence entitlement gating', () => {
render(() => <AIIntelligence />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Open work' })).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: /Check now/i }).length).toBeGreaterThan(0);
});
getCorrelationsMock.mockImplementation(() => new Promise(() => {}));
@ -914,7 +923,7 @@ describe('AIIntelligence entitlement gating', () => {
render(() => <AIIntelligence />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Open work' })).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: /Check now/i }).length).toBeGreaterThan(0);
});
fireEvent.click(screen.getAllByRole('button', { name: /Check now/i })[0]);
@ -1063,6 +1072,7 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.queryByText('VM 200')).not.toBeInTheDocument();
expect(screen.queryByText('Disk Full → Restart')).not.toBeInTheDocument();
await openPatrolActivityMode();
fireEvent.click(screen.getByRole('button', { name: 'History' }));
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Patrol history' })).toBeInTheDocument();
@ -1494,7 +1504,7 @@ describe('AIIntelligence entitlement gating', () => {
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Patrol' })).toBeInTheDocument();
expect(screen.getByText('Open work')).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Patrol decision inbox' })).toBeInTheDocument();
});
expect(screen.queryByText('Loading view...')).not.toBeInTheDocument();
@ -1514,7 +1524,7 @@ describe('AIIntelligence entitlement gating', () => {
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Patrol' })).toBeInTheDocument();
expect(screen.getByText('Patrol could not refresh')).toBeInTheDocument();
expect(screen.getByText('Open work')).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Patrol decision inbox' })).toBeInTheDocument();
});
expect(screen.queryByText('Loading view...')).not.toBeInTheDocument();
@ -1785,6 +1795,7 @@ describe('AIIntelligence entitlement gating', () => {
};
render(() => <AIIntelligence />);
await openPatrolActivityMode();
await waitFor(() => {
expect(screen.getByText('Open work')).toBeInTheDocument();
@ -2012,7 +2023,7 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.queryByTestId('patrol-status-bar')).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Open work' })).not.toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Needs your attention' })).toBeInTheDocument();
expect(screen.queryByRole('region', { name: 'Patrol decision inbox' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Active' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'All' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Resolved' })).not.toBeInTheDocument();
@ -2061,7 +2072,7 @@ describe('AIIntelligence entitlement gating', () => {
await waitFor(() => {
expect(screen.getByText('Patrol model issue')).toBeInTheDocument();
});
expect(screen.getByRole('heading', { name: 'Open work' })).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Patrol decision inbox' })).toBeInTheDocument();
const providerActions = screen.getAllByRole('link', { name: /Check Patrol model/i });
expect(providerActions.length).toBeGreaterThan(0);
expect(providerActions[0]).toHaveAttribute('href', '/settings/pulse-intelligence/patrol');
@ -2378,6 +2389,7 @@ describe('AIIntelligence entitlement gating', () => {
};
render(() => <AIIntelligence />);
await openPatrolActivityMode();
await waitFor(() => {
expect(getPatrolStatusMock).toHaveBeenCalled();
@ -2444,6 +2456,7 @@ describe('AIIntelligence entitlement gating', () => {
};
render(() => <AIIntelligence />);
await openPatrolActivityMode();
await waitFor(() => {
expect(getPatrolStatusMock).toHaveBeenCalled();
@ -2513,6 +2526,7 @@ describe('AIIntelligence entitlement gating', () => {
};
render(() => <AIIntelligence />);
await openPatrolActivityMode();
await waitFor(() => {
expect(getPatrolStatusMock).toHaveBeenCalled();
@ -2574,6 +2588,7 @@ describe('AIIntelligence entitlement gating', () => {
};
render(() => <AIIntelligence />);
await openPatrolActivityMode();
await waitFor(() => {
expect(getPatrolStatusMock).toHaveBeenCalled();

View file

@ -604,7 +604,10 @@ test.describe("Patrol Assistant operator briefing", () => {
});
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /Findings and run records/ }).click();
await page.getByRole("tab", { name: "Activity", exact: true }).click();
await page
.getByRole("button", { name: /Findings and run records/ })
.click();
await page.getByText("High CPU usage").click();
const findingReview = page.locator(
@ -740,7 +743,10 @@ test.describe("Patrol Assistant operator briefing", () => {
includePendingApproval = false;
await page.reload({ waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /Findings and run records/ }).click();
await page.getByRole("tab", { name: "Activity", exact: true }).click();
await page
.getByRole("button", { name: /Findings and run records/ })
.click();
const queuedFindingTitle = page.getByText("High CPU usage").first();
await expect(queuedFindingTitle).toBeVisible();
await queuedFindingTitle.click();
@ -770,14 +776,19 @@ test.describe("Patrol Assistant operator briefing", () => {
includeUnifiedInvestigationRecord = false;
includeInvestigationProposedFix = true;
await page.reload({ waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /Findings and run records/ }).click();
await page.getByRole("tab", { name: "Activity", exact: true }).click();
await page
.getByRole("button", { name: /Findings and run records/ })
.click();
const expiredFindingTitle = page.getByText("High CPU usage").first();
await expect(expiredFindingTitle).toBeVisible();
await expiredFindingTitle.click();
const expiredFinding = page.locator(
"#finding-finding-operator-briefing-details",
);
await expect(expiredFinding.getByText("Action details unavailable")).toBeVisible();
await expect(
expiredFinding.getByText("Action details unavailable"),
).toBeVisible();
await expiredFinding.getByText("Manage", { exact: true }).click();
await expiredFinding
.getByRole("button", { name: "Open in Assistant" })

View file

@ -293,8 +293,10 @@ const buildAttentionItem = (
eligibility: "eligible",
reasons: [],
evidenceIds: ["evidence-high-cpu"],
expectedPostcondition: "The agent reconnects and reports current CPU evidence.",
verificationPolicy: "Confirm a fresh agent heartbeat after restart.",
expectedPostcondition:
"The agent reconnects and reports current CPU evidence.",
verificationPolicy:
"Confirm a fresh agent heartbeat after restart.",
requiresApproval: true,
},
]
@ -714,7 +716,7 @@ test.describe("Monitor-first Patrol workbench browser contract", () => {
page.getByRole("heading", { level: 1, name: "Patrol" }),
).toBeVisible();
await expect(
page.getByRole("heading", { level: 2, name: "Needs your attention" }),
page.getByRole("heading", { level: 2, name: "No decisions are waiting" }),
).toBeVisible();
await expect(page.getByText("Nothing needs you right now")).toBeVisible();
await expect(
@ -760,7 +762,7 @@ test.describe("Monitor-first Patrol workbench browser contract", () => {
await page.getByRole("tab", { name: /Patrol/ }).click();
await expect(page).toHaveURL(/\/patrol$/);
await expect(
page.getByRole("heading", { level: 2, name: "Needs your attention" }),
page.getByRole("heading", { level: 2, name: "1 decision needs you" }),
).toBeVisible();
await expect(
page.getByRole("list", { name: "Patrol attention items" }),
@ -882,7 +884,10 @@ test.describe("Monitor-first Patrol workbench browser contract", () => {
await expect(
page.getByRole("heading", { level: 1, name: "Patrol" }),
).toBeVisible();
await page.getByRole("button", { name: /Findings and run records/ }).click();
await page.getByRole("tab", { name: "Activity", exact: true }).click();
await page
.getByRole("button", { name: /Findings and run records/ })
.click();
await expect(
page.getByText("Operating system updates need review").first(),
).toBeVisible();
@ -926,7 +931,10 @@ test.describe("Monitor-first Patrol workbench browser contract", () => {
}),
);
await page.reload({ waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /Findings and run records/ }).click();
await page.getByRole("tab", { name: "Activity", exact: true }).click();
await page
.getByRole("button", { name: /Findings and run records/ })
.click();
const cleanupTitle = page
.getByText("Downloaded package data is using needed space")
.first();
@ -981,7 +989,10 @@ test.describe("Monitor-first Patrol workbench browser contract", () => {
}),
);
await page.reload({ waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /Findings and run records/ }).click();
await page.getByRole("tab", { name: "Activity", exact: true }).click();
await page
.getByRole("button", { name: /Findings and run records/ })
.click();
await page.getByRole("button", { name: "Resolved", exact: true }).click();
const resolvedTitle = page
.getByText("Operating system updates confirmed complete")

View file

@ -686,32 +686,60 @@ async function mockAttention(
test("starts from the normal monitor shell and reaches the canonical attention queue", async ({
page,
}, testInfo) => {
}) => {
await mockAttention(page, "active");
await page.goto("/", { waitUntil: "domcontentloaded" });
const patrolNavigation = testInfo.project.name.startsWith("mobile-")
? page
.getByRole("navigation", { name: "Mobile navigation" })
.getByRole("button", { name: /Patrol/ })
: page
.getByRole("tab", { name: /Patrol/ })
.or(page.getByRole("link", { name: /Patrol/ }));
const patrolNavigation =
(page.viewportSize()?.width ?? 1280) < 1024
? page
.getByRole("navigation", { name: "Mobile navigation" })
.getByRole("button", { name: /Patrol/ })
: page
.getByRole("tab", { name: /Patrol/ })
.or(page.getByRole("link", { name: /Patrol/ }));
await patrolNavigation.click();
await expect(page).toHaveURL(/\/patrol/);
await expect(
page.getByRole("region", { name: "Needs your attention" }),
page.getByRole("region", { name: "Patrol decision inbox" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Open CPU pressure on pve-main" }),
).toBeVisible();
await expect(page.getByText("2 decisions need you")).toBeVisible();
if (!testInfo.project.name.startsWith("mobile-")) {
await expect(
page.getByRole("button", { name: "Review this decision" }),
).toBeVisible();
}
await expect(
page.getByRole("tab", { name: "Inbox", exact: true }),
).toHaveAttribute("aria-selected", "true");
await expect(
page.getByRole("heading", { name: "Protected outcomes", exact: true }),
).toHaveCount(0);
await expect(
page.getByRole("heading", { name: "Verified outcomes", exact: true }),
).toHaveCount(0);
await page.getByRole("tab", { name: "Protection", exact: true }).click();
await expect(
page.getByRole("heading", { name: "Protected outcomes", exact: true }),
).toBeVisible();
await expect(
page.getByRole("region", { name: "Patrol decision inbox" }),
).toHaveCount(0);
await page.getByRole("tab", { name: "Activity", exact: true }).click();
await expect(
page.getByRole("heading", { name: "Verified outcomes", exact: true }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Review and history", exact: true }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Protected outcomes", exact: true }),
).toHaveCount(0);
await page.getByRole("tab", { name: "Inbox", exact: true }).click();
await expect(
page.getByRole("region", { name: "Patrol decision inbox" }),
).toBeVisible();
});
test("makes active operational work primary and preserves the evidence boundary", async ({
@ -727,8 +755,7 @@ test("makes active operational work primary and preserves the evidence boundary"
}),
),
).toBeVisible();
const queue = page.getByRole("region", { name: "Needs your attention" });
await expect(queue.getByLabel("2 items require review")).toBeVisible();
const queue = page.getByRole("region", { name: "Patrol decision inbox" });
const attentionList = queue.getByRole("list", {
name: "Patrol attention items",
});

View file

@ -587,7 +587,7 @@ test.describe("Operational trust availability resource facet", () => {
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
const queue = page.getByRole("region", { name: "Needs your attention" });
const queue = page.getByRole("region", { name: "Patrol decision inbox" });
await expect(queue).toBeVisible({ timeout: 30_000 });
await expect(
page.getByRole("tab", {