Rework Patrol around outcomes and quiet autonomy

This commit is contained in:
rcourtman
2026-08-14 09:45:30 +01:00
parent e5cad4c2c3
commit 772ff3087d
19 changed files with 1052 additions and 324 deletions
@@ -7378,6 +7378,17 @@ The frontend Patrol history clients in `frontend-modern/src/api/patrol.ts`
must mirror that normalization before sending the request: invalid and
non-positive caller input collapses back to the client default of `30`, and
oversized requests clamp to the backend maximum of `100`.
The outcome-first Patrol home consumes the existing typed attention contract;
it does not create a second server lifecycle. `GET /api/ai/patrol/attention`
with `filter=active` remains the source for current typed evidence and governed
action offers, while `filter=resolved` supplies receipt candidates. Frontend
presentation may call a resolved item handled only when the API-authored
`state` is `resolved` and `verificationState` is `succeeded`; it must preserve
pending, failed, unknown, and unavailable verification as non-receipts. The
frontend may use action eligibility and approval fields to decide whether an
item needs another operator decision under the effective autonomy level, but
that projection grants no action authority and cannot override server planning,
policy, approval, dispatch, or verification.
Patrol run detail access for selected-history UX must now resolve a canonical
single-run contract at `/api/ai/patrol/runs/{id}` instead of probing bounded
history pages and hoping the target run is still inside a recent window; the
@@ -771,6 +771,13 @@ Feature surfaces under `frontend-modern/src/features/` may own product-specific
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, objectives,
genuine decisions, 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
daily-page primitive and must not be added beside the effective mode.
For Patrol, that includes the Open work description: it may use concise
row-level guidance such as review evidence, approve a change, inspect automatic
actions, or review verification results, but it must remain descriptive copy
@@ -15,9 +15,10 @@
## Purpose
Own the Patrol intelligence route shell, feature surface, local state
orchestration, findings and approval presentation, run-history rendering, and
Patrol-specific presentation helpers.
Own the Patrol intelligence route shell, outcome-first feature surface, local
state orchestration, genuine-decision presentation, verified-work receipts,
findings and approval presentation, run-history rendering, and Patrol-specific
presentation helpers.
The centralized Findings surface loads full history for its All and Resolved
filters, keeps saved operator notes editable and clearable, and offers
`Reopen finding` only on dismissed findings. Reopening removes the
@@ -62,6 +63,8 @@ sources, and retains the note as operator context.
34. `tests/integration/tests/91-operational-trust-attention-workbench.spec.ts`
35. `frontend-modern/src/api/patrolAttention.ts`
36. `frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx`
37. `frontend-modern/src/features/patrol/patrolHomePresentation.ts`
38. `frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx`
## Shared Boundaries
@@ -69,6 +72,40 @@ sources, and retains the note as operator context.
## Extension Points
### 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,
the outcomes Patrol is looking after, work that genuinely needs the operator,
and concise recent receipts. Investigation internals, lifecycle filters, raw
findings, checks, and run history remain available as secondary operational
records without competing with those four questions.
`patrolHomePresentation.ts` owns the deterministic projection from effective
autonomy and typed attention evidence into `needs user` versus `can continue`
work. Watch-only and ask-first modes keep current active issues operator-visible.
Safe-auto-fix and Autopilot may keep an issue quiet only when evidence is fresh
and complete and at least one eligible governed action can proceed without a
new approval. Unknown or stale evidence, failed or unknown verification,
ineligible or absent actions, policy denial, and outstanding approval remain
interruptions. This is presentation of existing authority, never a new grant of
authority or a bypass around planning, feasibility, approval, dispatch, and
verification.
The `Recently handled` surface is proof-constrained. It may render a receipt
only for a resolved attention record whose verification state is `succeeded`,
and it describes the resolved record rather than inferring which offered action
ran. Pending, failed, unknown, or unavailable verification must never be styled
as handled work. Objective summary language similarly counts protection only
from active objectives whose server-authored coverage state is `covered`.
The effective autonomy label and plain-language consequence stay visible, but
the four-level selector is a secondary disclosure. A plan-locked daily operator
surface shows the effective watch-only experience without advertising paid
modes or Plans & Billing; a wrong-runtime capability block may still expose its
direct runtime repair action. Mobile must reach protected outcomes and genuine
decisions without first traversing configuration or commercial chrome.
### Model-authored operational objectives
Patrol's long-term operator contract is a retained operations brief, not a
@@ -6121,12 +6121,14 @@
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx",
"frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts",
"frontend-modern/src/features/patrol/patrolControlPresentation.ts",
"frontend-modern/src/features/patrol/patrolHomePresentation.ts",
"frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx",
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx",
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx",
"frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx",
"frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts",
"frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx",
"frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx",
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts",
"frontend-modern/src/pages/AIIntelligence.tsx",
"frontend-modern/src/stores/aiIntelligence.ts",
@@ -6169,12 +6171,14 @@
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx",
"frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts",
"frontend-modern/src/features/patrol/patrolControlPresentation.ts",
"frontend-modern/src/features/patrol/patrolHomePresentation.ts",
"frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx",
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx",
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx",
"frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx",
"frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts",
"frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx",
"frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx",
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts",
"frontend-modern/src/pages/AIIntelligence.tsx",
"frontend-modern/src/stores/aiIntelligence.ts",
@@ -6190,9 +6194,11 @@
"frontend-modern/src/components/Brand/__tests__/PulsePatrolLogo.test.tsx",
"frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx",
"frontend-modern/src/features/patrol/__tests__/patrolControlPresentation.test.ts",
"frontend-modern/src/features/patrol/__tests__/patrolHomePresentation.test.ts",
"frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts",
"frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts",
"frontend-modern/src/features/patrol/__tests__/PatrolObjectivesPanel.test.tsx",
"frontend-modern/src/features/patrol/__tests__/PatrolRecentWorkPanel.test.tsx",
"frontend-modern/src/features/patrol/__tests__/patrolRunAcceptance.test.ts",
"frontend-modern/src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts",
"frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx",
+30 -27
View File
@@ -1,25 +1,29 @@
{
"version": 1,
"base_sha": "6f09bc46768e809ec882f4c8c6782fae1e2ab710",
"verified_at": "2026-08-14T02:02:23Z",
"base_sha": "e5cad4c2c34529c0b15d8c32b8d1e53d0ddd84ad",
"verified_at": "2026-08-14T08:26:04Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/api/patrol.ts",
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx",
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx",
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx",
"frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx"
"frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx",
"frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx",
"frontend-modern/src/features/patrol/patrolHomePresentation.ts"
],
"content_sha256": {
"frontend-modern/src/api/patrol.ts": "59e86aab6a4eb168de3563589cccb912d0ed6c275054d55544620598ab690496",
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "8a0e42b07fa23cfd1a9c308986cff36bfe377dc2c1d2d84d0286af06709489b1",
"frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx": "44856c28de9cc1bb81900517876791666cb5e86fdde3e3dd759617a691c99b44"
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx": "01f1010536c0b1551a09fa5634c9714cb771f10a37b274ae068aa1bbd3860b59",
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx": "41aa54fdf6d23ceb0019455e2a112b47d0164cf2e1dd36abc45f8faf47bc77e9",
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "780a27fcab535413b8446c5ec89637f98c08af1d8b57f77157028e499ad6b4d4",
"frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx": "8a68062a24352ac46384030bd267f1143572588ff36f061ec0cbed3c03555da4",
"frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx": "6d2cf91943e49db10cd94c6639390297ddaff170ebf136b6c48e991cd14c134d",
"frontend-modern/src/features/patrol/patrolHomePresentation.ts": "18799e39f1bd4cdeaf93e494f4b84fc898aef356b111b030687885cbfdbd478b"
},
"routes": [
"/patrol"
],
"routes": ["/patrol"],
"viewports": [
{
"width": 1440,
"height": 900
"height": 1000
},
{
"width": 390,
@@ -27,23 +31,22 @@
}
],
"states": [
"initial loading and empty objective list",
"add-objective dialog open and closed",
"resource-scoped objective with disabled submit until an outcome is entered",
"persisted objective after reload with truthful uncovered coverage",
"paused and resumed objective states",
"edit-objective dialog kept open across background polling",
"clean empty state after objective deletion",
"desktop and narrow layouts without horizontal overflow, clipping, or console errors"
"watch-only background posture with effective autonomy consequence and no commercial upgrade action",
"empty objective list with honest zero-protection summary",
"ten genuine user decisions compacted to five initial rows",
"verified-receipt empty state without implying successful work",
"autonomy disclosure expanded",
"objective creation dialog open and dismissed",
"typed attention detail open and closed with evidence, protection, and timeline",
"desktop and narrow layouts without horizontal overflow"
],
"interactions": [
"opened the add-objective dialog, selected a real resource, entered an outcome, and saved it",
"reloaded the Patrol route and confirmed the objective persisted",
"paused and resumed the objective",
"opened the edit dialog and confirmed polling did not dismiss or overwrite it",
"dismissed dialogs with Escape and confirmed focus returned to the triggering control",
"confirmed body scrolling locked while the narrow dialog was open and unlocked on dismissal",
"deleted the temporary objective and confirmed the empty state returned",
"verified document scroll width matched the 390px viewport"
"expanded the secondary How Patrol operates disclosure and confirmed Watch only without Plans & Billing",
"opened the add-objective dialog, inspected the outcome-first form, cancelled it, and dismissed it with Escape",
"confirmed objective-dialog focus returned to Add objective after Escape",
"opened the deepest attention detail and inspected lifecycle, evidence, protection, timeline, and resource actions",
"closed attention detail and confirmed focus returned to the originating decision row",
"expanded all ten decisions, collapsed back to five, and confirmed the compact queue counts",
"verified document width matched both 1440px and 390px viewports"
]
}
@@ -20,7 +20,6 @@ import ShieldOffIcon from 'lucide-solid/icons/shield-off';
import SparklesIcon from 'lucide-solid/icons/sparkles';
import XIcon from 'lucide-solid/icons/x';
import type {
AttentionFilter,
AttentionActionOffer,
AttentionItem,
AttentionItemDetail,
@@ -34,7 +33,6 @@ import {
} from '@/api/patrolAttention';
import { ResourceActionsAPI } from '@/api/resourceActions';
import { Button, ButtonLink } from '@/components/shared/Button';
import { LabeledFilterToggleGroup } from '@/components/shared/FilterToolbar';
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
import { MetadataBadge, type MetadataBadgeTone } from '@/components/shared/MetadataBadge';
import { ActionReviewDialog } from '@/features/actions/ActionReviewDialog';
@@ -56,19 +54,23 @@ import type { EvidenceEnvelope } from '@/types/operationalTrust';
import type { ActionDetailResponse } from '@/types/actionAudit';
import { getAlertResourceIncidentAcknowledgedByLabel } from '@/utils/alertIncidentPresentation';
import { formatRelativeTime } from '@/utils/format';
import type { PatrolAutonomyLevel } from '@/api/patrol';
import {
partitionPatrolAttention,
PATROL_AUTONOMY_EXPERIENCE,
type PatrolAttentionDecision,
} from './patrolHomePresentation';
const PRIMARY_EVIDENCE_LIMIT = 3;
const PRIMARY_DECISION_LIMIT = 5;
const FILTERS: Array<{ id: AttentionFilter; label: string }> = [
{ id: 'active', label: 'Active' },
{ id: 'open', label: 'Open' },
{ id: 'acknowledged', label: 'Acknowledged' },
{ id: 'suppressed', label: 'Suppressed' },
{ id: 'stale_unknown', label: 'Stale or unknown' },
{ id: 'resolved', label: 'Recent resolved' },
];
export function PatrolAttentionWorkbench(props: { onOpenFindings?: () => void } = {}) {
export function PatrolAttentionWorkbench(
props: {
autonomyLevel?: PatrolAutonomyLevel;
autonomyLocked?: boolean;
onOpenFindings?: () => void;
} = {},
) {
const location = useLocation();
const [selectedItemId, setSelectedItemId] = createSignal('');
const [actionDetail, setActionDetail] = createSignal<ActionDetailResponse | null>(null);
@@ -76,40 +78,28 @@ export function PatrolAttentionWorkbench(props: { onOpenFindings?: () => void }
const [actionError, setActionError] = createSignal('');
const [lifecycleBusy, setLifecycleBusy] = createSignal(false);
const [lifecycleError, setLifecycleError] = createSignal('');
const [showAllDecisions, setShowAllDecisions] = createSignal(false);
const itemButtons = new Map<string, HTMLButtonElement>();
let detailPanel: HTMLDivElement | undefined;
let actionTrigger: HTMLButtonElement | undefined;
const selectedDetail = () => patrolAttentionStore.selectedDetail();
const summary = () => patrolAttentionStore.summary();
const filterCount = (filter: AttentionFilter): number | undefined => {
const value = summary();
if (!value) return undefined;
switch (filter) {
case 'active':
return value.activeCount;
case 'open':
return value.openCount;
case 'acknowledged':
return value.acknowledgedCount;
case 'suppressed':
return value.suppressedCount;
case 'stale_unknown':
return value.uncertainCount;
case 'resolved':
return value.resolvedCount;
default:
return undefined;
}
};
const attentionFilterOptions = createMemo(() =>
FILTERS.map((option) => {
const count = filterCount(option.id);
return {
value: option.id,
label: count === undefined ? option.label : `${option.label} ${count}`,
};
}),
const effectiveAutonomyLevel = createMemo<PatrolAutonomyLevel>(() =>
props.autonomyLocked ? 'monitor' : (props.autonomyLevel ?? 'monitor'),
);
const autonomyExperience = createMemo(() => PATROL_AUTONOMY_EXPERIENCE[effectiveAutonomyLevel()]);
const attention = createMemo(() =>
partitionPatrolAttention(
patrolAttentionStore.items(),
props.autonomyLevel ?? 'monitor',
props.autonomyLocked ?? false,
),
);
const visibleDecisions = createMemo(() =>
showAllDecisions()
? attention().needsUser
: attention().needsUser.slice(0, PRIMARY_DECISION_LIMIT),
);
const loadCurrentFilter = () => patrolAttentionStore.load(patrolAttentionStore.filter());
@@ -133,10 +123,6 @@ export function PatrolAttentionWorkbench(props: { onOpenFindings?: () => void }
void patrolAttentionStore.select(null);
queueMicrotask(() => itemButtons.get(previous)?.focus());
};
const changeFilter = (filter: AttentionFilter) => {
closeDetail();
void patrolAttentionStore.load(filter);
};
const reviewAction = async (
item: AttentionItem,
offer: AttentionActionOffer,
@@ -205,6 +191,7 @@ export function PatrolAttentionWorkbench(props: { onOpenFindings?: () => void }
const deepLinkedItem = parsePatrolAttentionItemId(location.search);
const currentItem = untrack(selectedItemId);
if (deepLinkedItem && deepLinkedItem !== currentItem) {
setShowAllDecisions(true);
setSelectedItemId(deepLinkedItem);
void patrolAttentionStore.select(deepLinkedItem);
scrollDetailIntoView();
@@ -214,10 +201,9 @@ export function PatrolAttentionWorkbench(props: { onOpenFindings?: () => void }
}
});
const activeCountLabel = createMemo(() => {
const count = summary()?.activeCount;
if (count === undefined) return 'Attention count unavailable';
return `${count} active attention ${count === 1 ? 'item' : 'items'}`;
const decisionCountLabel = createMemo(() => {
const count = attention().needsUser.length;
return `${count} ${count === 1 ? 'decision needs' : 'decisions need'} you`;
});
return (
@@ -230,22 +216,21 @@ export function PatrolAttentionWorkbench(props: { onOpenFindings?: () => void }
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h2 id="patrol-attention-heading" class="text-base font-semibold text-base-content">
Needs attention
Needs you
</h2>
<Show when={summary()}>
<MetadataBadge
tone={(summary()?.activeCount ?? 0) > 0 ? 'warning' : 'success'}
tone={attention().needsUser.length > 0 ? 'warning' : 'success'}
size="sm"
shape="rounded"
aria-label={activeCountLabel()}
aria-label={decisionCountLabel()}
>
{summary()?.activeCount ?? 0}
{attention().needsUser.length}
</MetadataBadge>
</Show>
</div>
<p class="mt-1 max-w-3xl text-sm leading-5 text-muted">
Current operational issues, ordered by urgency, affected resources, protection
concern, evidence quality, and age.
{autonomyExperience().needsYouDescription}
</p>
</div>
<Button
@@ -263,17 +248,13 @@ export function PatrolAttentionWorkbench(props: { onOpenFindings?: () => void }
Refresh
</Button>
</div>
<div class="mt-4">
<LabeledFilterToggleGroup
id="patrol-attention-state"
label="Attention state"
value={patrolAttentionStore.filter()}
onChange={(value) => changeFilter(value as AttentionFilter)}
options={attentionFilterOptions()}
selectClass="min-w-[11rem]"
/>
</div>
<Show when={attention().quiet.length > 0}>
<p class="mt-3 rounded-md border border-border-subtle bg-surface-alt/40 px-3 py-2 text-xs leading-5 text-muted">
{attention().quiet.length} other current{' '}
{attention().quiet.length === 1 ? 'issue does' : 'issues do'} not require a decision.{' '}
{autonomyExperience().quietWorkDescription}
</p>
</Show>
</div>
<div
@@ -283,6 +264,7 @@ export function PatrolAttentionWorkbench(props: { onOpenFindings?: () => void }
class={`min-w-0 ${selectedItemId() ? 'border-b border-border lg:border-b-0 lg:border-r' : ''}`}
>
<AttentionList
decisions={visibleDecisions()}
selectedItemId={selectedItemId()}
itemButtons={itemButtons}
onSelect={selectItem}
@@ -312,6 +294,19 @@ export function PatrolAttentionWorkbench(props: { onOpenFindings?: () => void }
</div>
</Show>
</div>
<Show when={attention().needsUser.length > PRIMARY_DECISION_LIMIT}>
<div class="border-t border-border px-4 py-3 text-center sm:px-5">
<Button
variant="secondary"
size="sm"
onClick={() => setShowAllDecisions((current) => !current)}
>
{showAllDecisions()
? 'Show fewer decisions'
: `Show all ${attention().needsUser.length} decisions`}
</Button>
</div>
</Show>
<ActionReviewDialog
detail={actionDetail()}
onClose={closeActionReview}
@@ -329,6 +324,7 @@ function replaceAttentionLocation(itemId: string) {
}
function AttentionList(props: {
decisions: PatrolAttentionDecision[];
selectedItemId: string;
itemButtons: Map<string, HTMLButtonElement>;
onSelect: (itemId: string) => void;
@@ -351,7 +347,7 @@ function AttentionList(props: {
</Show>
<Show
when={!patrolAttentionStore.loading() || patrolAttentionStore.items().length > 0}
when={!patrolAttentionStore.loading() || props.decisions.length > 0}
fallback={
<div class="flex min-h-40 items-center justify-center gap-2 text-sm text-muted">
<LoadingSpinner size="sm" />
@@ -359,52 +355,63 @@ function AttentionList(props: {
</div>
}
>
<Show when={patrolAttentionStore.items().length > 0} fallback={<AttentionEmptyState />}>
<Show
when={props.decisions.length > 0}
fallback={<AttentionEmptyState hasQuietWork={patrolAttentionStore.items().length > 0} />}
>
<ul class="divide-y divide-border" aria-label="Patrol attention items">
<For each={patrolAttentionStore.items()}>
{(item) => (
<li>
<button
ref={(element) => props.itemButtons.set(item.id, element)}
type="button"
class={`w-full px-4 py-3 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 sm:px-5 ${
props.selectedItemId === item.id
? 'bg-blue-50/80 dark:bg-blue-950/30'
: 'hover:bg-surface-hover'
}`}
aria-pressed={props.selectedItemId === item.id}
aria-label={`Open ${item.title}`}
onClick={() => props.onSelect(item.id)}
>
<div class="flex min-w-0 items-start gap-3">
<SeverityMarker item={item} />
<div class="min-w-0 flex-1">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<span class="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="truncate font-medium text-base-content">
{item.subjectResourceName}
</span>
<EvidenceLabel item={item} />
<ProtectionLabel item={item} />
<span>{formatRelativeTime(item.firstObservedAt, { compact: true })}</span>
<For each={props.decisions}>
{(decision) => {
const item = decision.item;
return (
<li>
<button
ref={(element) => props.itemButtons.set(item.id, element)}
type="button"
class={`w-full px-4 py-3 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 sm:px-5 ${
props.selectedItemId === item.id
? 'bg-blue-50/80 dark:bg-blue-950/30'
: 'hover:bg-surface-hover'
}`}
aria-pressed={props.selectedItemId === item.id}
aria-label={`Open ${item.title}`}
onClick={() => props.onSelect(item.id)}
>
<div class="flex min-w-0 items-start gap-3">
<SeverityMarker item={item} />
<div class="min-w-0 flex-1">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<span class="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>
<p class="mt-1 text-xs font-medium leading-5 text-amber-700 dark:text-amber-300">
{decision.reason}
</p>
<div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
<span class="truncate font-medium text-base-content">
{item.subjectResourceName}
</span>
<EvidenceLabel item={item} />
<ProtectionLabel item={item} />
<span>
{formatRelativeTime(item.firstObservedAt, { compact: true })}
</span>
</div>
</div>
<ChevronRightIcon
class="mt-1 h-4 w-4 shrink-0 text-muted"
aria-hidden="true"
/>
</div>
<ChevronRightIcon
class="mt-1 h-4 w-4 shrink-0 text-muted"
aria-hidden="true"
/>
</div>
</button>
</li>
)}
</button>
</li>
);
}}
</For>
</ul>
</Show>
@@ -413,7 +420,7 @@ function AttentionList(props: {
);
}
function AttentionEmptyState() {
function AttentionEmptyState(props: { hasQuietWork: boolean }) {
const summary = () => patrolAttentionStore.summary();
const activeFilter = () => patrolAttentionStore.filter() === 'active';
const trustworthyCalm = () =>
@@ -425,7 +432,7 @@ function AttentionEmptyState() {
return (
<div class="flex min-h-52 flex-col items-center justify-center px-6 py-10 text-center">
<Show
when={trustworthyCalm()}
when={trustworthyCalm() || props.hasQuietWork}
fallback={
<>
<ClockIcon class="h-8 w-8 text-muted" aria-hidden="true" />
@@ -439,9 +446,11 @@ function AttentionEmptyState() {
}
>
<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 your attention</h3>
<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">
The current operational lifecycle evaluation has no active items.
{props.hasQuietWork
? 'Patrol can continue with the current issues under this mode.'
: 'The current operational evaluation has no active items.'}
<Show when={summary()?.evaluatedAt}>
{(evaluatedAt) => ` Checked ${formatRelativeTime(evaluatedAt(), { compact: true })}.`}
</Show>
@@ -3,7 +3,6 @@ import { A } from '@solidjs/router';
import PlayIcon from 'lucide-solid/icons/play';
import SettingsIcon from 'lucide-solid/icons/settings';
import DownloadIcon from 'lucide-solid/icons/download';
import CreditCardIcon from 'lucide-solid/icons/credit-card';
import { PulsePatrolLogo } from '@/components/Brand/PulsePatrolLogo';
import { PageHeader } from '@/components/shared/PageHeader';
import { TogglePrimitive } from '@/components/shared/Toggle';
@@ -14,20 +13,17 @@ import { UpgradeButtonLink } from '@/components/shared/UpgradeLink';
import type { PatrolAutonomyLevel } from '@/api/patrol';
import { settingsTabPath } from '@/components/Settings/settingsNavigationModel';
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
import {
presentationPolicyHidesCommercialSurfaces,
presentationPolicyHidesUpgradePrompts,
} from '@/stores/sessionPresentationPolicy';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { formatRelativeTime } from '@/utils/format';
import { getPatrolPageHeaderMeta } from '@/utils/patrolPagePresentation';
import { getPatrolTriggerStatusSummary } from '@/utils/patrolRunPresentation';
import { getPatrolRuntimePresentation } from '@/utils/patrolRuntimePresentation';
import { getPatrolSetupAction } from '@/utils/patrolRuntimeActions';
import { getPatrolRecencyPresentation } from '@/utils/patrolSummaryPresentation';
import { PATROL_CONTROL_ANCHOR, PATROL_OPERATIONS_LOOP_ANCHOR } from '@/routing/resourceLinks';
import type { PatrolConfigurationFailureInput } from './patrolInvestigationContextModel';
import { getPatrolAutonomyAvailabilityPresentation } from './patrolAutonomyAvailability';
import { PATROL_AUTONOMY_POLICY_PRESENTATION } from './patrolControlPresentation';
import { PATROL_AUTONOMY_EXPERIENCE } from './patrolHomePresentation';
import type { PatrolIntelligenceState } from './usePatrolIntelligenceState';
import { PatrolAutopilotAcknowledgementDialog } from './PatrolAutopilotAcknowledgementDialog';
@@ -60,9 +56,6 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
autonomyLocked: state.autoFixLocked(),
}),
);
const runtimePresentation = createMemo(() =>
getPatrolRuntimePresentation(state.runtimeState(), state.blockedReason()),
);
const recency = createMemo(() =>
getPatrolRecencyPresentation({
runs: state.patrolRunHistory.value() ?? [],
@@ -94,7 +87,7 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
? 'Starting…'
: state.manualRunRequested() || state.patrolStream.isStreaming()
? 'Running…'
: 'Run Patrol',
: 'Check now',
);
const renderRunControl = (className: string) => (
<Show
@@ -128,14 +121,16 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
const selectedAutonomyPolicy = createMemo(
() => PATROL_AUTONOMY_POLICY_PRESENTATION[effectiveAutonomyLevel()],
);
const selectedAutonomyExperience = createMemo(
() => PATROL_AUTONOMY_EXPERIENCE[effectiveAutonomyLevel()],
);
const upgradePromptsHidden = createMemo(() => presentationPolicyHidesUpgradePrompts());
const commercialSurfacesHidden = createMemo(() => presentationPolicyHidesCommercialSurfaces());
const canChooseAutonomyLevel = createMemo(() => !state.autoFixLocked());
const autonomyAvailability = createMemo(() =>
getPatrolAutonomyAvailabilityPresentation({
autoFixLocked: state.autoFixLocked(),
upgradePromptsHidden: upgradePromptsHidden(),
commercialSurfacesHidden: commercialSurfacesHidden(),
commercialSurfacesHidden: true,
runtimeCapabilityBlock: state.autoFixCapabilityBlock(),
runtime: state.licenseRuntimeIdentity(),
planUpgradeDestination: getUpgradeActionDestination('ai_autofix'),
@@ -150,15 +145,7 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
state.autoFixLocked() &&
autonomyAvailability().kind === 'runtime_locked',
);
const showAutonomyPlanBillingAction = createMemo(
() =>
state.autoFixLocked() &&
autonomyAvailability().kind === 'plan_locked' &&
Boolean(autonomyAvailability().actionLabel && autonomyAvailability().destination?.href),
);
const shouldShowAutonomyActionColumn = createMemo(
() => shouldShowAutonomyOptions() || showAutonomyPlanBillingAction(),
);
const shouldShowAutonomyActionColumn = createMemo(() => shouldShowAutonomyOptions());
const showAutonomyAvailabilityPrompt = createMemo(
() =>
state.autoFixLocked() &&
@@ -175,24 +162,12 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
(['monitor', 'approval', 'assisted', 'full'] as const).map((level) => {
const presentation = PATROL_AUTONOMY_POLICY_PRESENTATION[level];
const lockedPaidMode = state.autoFixLocked() && level !== 'monitor';
const showProBadge = lockedPaidMode && !commercialSurfacesHidden();
return {
value: level,
label: presentation.label,
compactLabel: presentation.compactLabel,
ariaLabel: showProBadge ? `${presentation.label} Pro` : undefined,
title: lockedPaidMode
? `${presentation.detail} Available with Pulse Pro.`
: presentation.detail,
title: presentation.detail,
disabled: lockedPaidMode,
visualLabel: showProBadge ? (
<>
<span>{presentation.label}</span>
<span class="rounded border border-blue-200 bg-blue-50 px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300">
Pro
</span>
</>
) : undefined,
};
}),
);
@@ -229,17 +204,6 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
variant={options.variant ?? 'segmented'}
/>
</Show>
<Show when={showAutonomyPlanBillingAction()}>
<UpgradeButtonLink
destination={autonomyAvailability().destination!}
size="sm"
mobileFullWidth={false}
class="self-start lg:self-end"
>
<CreditCardIcon class="h-4 w-4" />
{autonomyAvailability().actionLabel}
</UpgradeButtonLink>
</Show>
</div>
</Show>
</div>
@@ -326,91 +290,98 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
</span>
}
class="relative z-[200] mb-3"
actions={
<div class="hidden sm:flex flex-wrap items-center justify-end gap-3">
<Show when={!state.shouldShowPatrolSetupOnly()}>
<>
<Show when={recency().timestamp}>
<div class="flex items-center gap-3 text-xs text-muted">
<span>
{recency().label}:{' '}
{formatRelativeTime(recency().timestamp, {
compact: true,
emptyText: 'Never',
})}
<Show when={recency().resourcesCheckedLabel}>
{' '}
<span class="text-muted"> {recency().resourcesCheckedLabel}</span>
</Show>
</span>
<Show when={state.patrolStatus()?.next_patrol_at}>
<span class="text-muted">|</span>
<CountdownTimer
targetDate={state.patrolStatus()!.next_patrol_at!}
prefix="Next run: "
class="font-variant-numeric tabular-nums font-medium text-blue-600 dark:text-blue-400"
/>
</Show>
</div>
</Show>
{renderRunControl(
'flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-surface-alt disabled:text-muted rounded-md transition-colors',
)}
</>
</Show>
</div>
}
/>
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<TogglePrimitive
checked={state.patrolEnabledLocal()}
disabled={state.isTogglingPatrol()}
onToggle={state.handleTogglePatrol}
size="sm"
ariaLabel="Toggle Patrol"
/>
<span class="text-sm font-medium text-base-content">{runtimePresentation().label}</span>
<section class="rounded-lg border border-border bg-surface px-4 py-4 shadow-sm sm:px-5">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="flex min-w-0 items-start gap-3">
<div class="pt-0.5">
<TogglePrimitive
checked={state.patrolEnabledLocal()}
disabled={state.isTogglingPatrol()}
onToggle={state.handleTogglePatrol}
size="sm"
ariaLabel="Toggle Patrol"
/>
</div>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-sm font-semibold text-base-content">
{state.patrolEnabledLocal()
? 'Patrol is looking after your infrastructure'
: 'Patrol is off'}
</h2>
<span class="rounded-full border border-border-subtle bg-surface-alt px-2 py-0.5 text-xs font-medium text-base-content">
{selectedAutonomyExperience().label}
</span>
</div>
<p class="mt-1 max-w-3xl text-sm leading-5 text-muted">
{state.patrolEnabledLocal()
? selectedAutonomyExperience().summary
: 'Turn Patrol on when you want it to watch your estate in the background.'}
</p>
<Show when={!state.shouldShowPatrolSetupOnly() && triggerStatusSummary()}>
<p class="mt-1 text-xs leading-5 text-muted">{triggerStatusSummary()}</p>
</Show>
<Show when={!state.shouldShowPatrolSetupOnly() && recency().timestamp}>
<p class="mt-1 text-xs leading-5 text-muted">
{recency().label}:{' '}
{formatRelativeTime(recency().timestamp, {
compact: true,
emptyText: 'Never',
})}
<Show when={recency().resourcesCheckedLabel}>
{' '}
{recency().resourcesCheckedLabel}
</Show>
<Show when={state.patrolStatus()?.next_patrol_at}>
{' '}
·{' '}
<CountdownTimer
targetDate={state.patrolStatus()!.next_patrol_at!}
prefix="Next check: "
class="font-variant-numeric tabular-nums"
/>
</Show>
</p>
</Show>
</div>
</div>
<Show when={!state.shouldShowPatrolSetupOnly()}>
<div class="flex shrink-0 flex-wrap items-center gap-2">
{renderRunControl(
'flex min-h-11 items-center gap-2 rounded-md border border-border bg-surface px-3 py-1.5 text-sm font-medium text-base-content shadow-sm transition-colors hover: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 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"
>
<SettingsIcon class="h-4 w-4" />
Settings
</A>
</Show>
</div>
</Show>
</div>
<Show when={!state.shouldShowPatrolSetupOnly() && triggerStatusSummary()}>
<span class="max-w-full text-xs leading-5 text-muted">{triggerStatusSummary()}</span>
</Show>
<Show when={!state.shouldShowPatrolSetupOnly()}>
<div class="flex flex-wrap items-center gap-2 sm:ml-auto">
{renderRunControl(
'flex min-h-11 items-center gap-2 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:bg-surface-alt disabled:text-muted sm:hidden',
)}
<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 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"
>
<SettingsIcon class="w-4 h-4" />
<span class="sr-only sm:not-sr-only">Settings</span>
</A>
</Show>
<details id={PATROL_CONTROL_ANCHOR} class="mt-4 border-t border-border-subtle pt-3">
<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">
How Patrol operates
</summary>
<div class="pt-3">
<span id={PATROL_OPERATIONS_LOOP_ANCHOR} class="sr-only" aria-hidden="true" />
{renderAutonomyPolicyControl({
ariaLabel: 'Patrol mode',
layoutClass: 'flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between',
controlClass: 'w-full lg:w-[34rem]',
})}
</div>
</Show>
</div>
<div
id={PATROL_CONTROL_ANCHOR}
class="rounded-md border border-border-subtle bg-surface-alt/50 px-3 py-3"
>
<span id={PATROL_OPERATIONS_LOOP_ANCHOR} class="sr-only" aria-hidden="true" />
{renderAutonomyPolicyControl({
ariaLabel: 'Patrol mode',
layoutClass: 'flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between',
controlClass: 'w-full lg:w-[34rem]',
})}
</div>
</details>
</section>
</div>
);
}
@@ -7,6 +7,7 @@ import { PatrolIntelligenceBanners } from './PatrolIntelligenceBanners';
import { PatrolIntelligenceWorkspace } from './PatrolIntelligenceWorkspace';
import { PatrolAttentionWorkbench } from './PatrolAttentionWorkbench';
import { PatrolObjectivesPanel } from './PatrolObjectivesPanel';
import { PatrolRecentWorkPanel } from './PatrolRecentWorkPanel';
export function PatrolIntelligenceSurface() {
const state = usePatrolIntelligenceState();
@@ -26,7 +27,12 @@ export function PatrolIntelligenceSurface() {
<PatrolIntelligenceHeader state={state} />
<PatrolIntelligenceBanners state={state} />
<PatrolObjectivesPanel />
<PatrolAttentionWorkbench onOpenFindings={openFindings} />
<PatrolAttentionWorkbench
autonomyLevel={state.autonomyLevel()}
autonomyLocked={state.autoFixLocked()}
onOpenFindings={openFindings}
/>
<PatrolRecentWorkPanel />
<details
class="rounded-lg border border-border bg-surface"
@@ -38,7 +44,7 @@ export function PatrolIntelligenceSurface() {
class="cursor-pointer px-4 py-3 text-sm font-medium text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 sm:px-5"
>
<span class="inline-flex flex-wrap items-center gap-2">
Patrol checks, investigations, and run history
Operational records and run history
<Show when={openWorkCount() > 0}>
<MetadataBadge
tone="info"
@@ -20,6 +20,7 @@ import {
import { useResources } from '@/hooks/useResources';
import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity';
import { showError, showSuccess } from '@/utils/toast';
import { getPatrolObjectiveProtectionSummary } from './patrolHomePresentation';
const coveragePresentation = (
state: PatrolObjectiveCoverageState,
@@ -55,6 +56,7 @@ export const PatrolObjectivesPanel: Component = () => {
const resourceById = createMemo(
() => new Map(resources().map((resource) => [resource.id, resource])),
);
const protectionSummary = createMemo(() => getPatrolObjectiveProtectionSummary(objectives()));
const loadObjectives = async (quiet = false) => {
if (!quiet) setLoading(true);
@@ -195,7 +197,7 @@ export const PatrolObjectivesPanel: Component = () => {
<div class="flex flex-col gap-3 border-b border-border px-4 py-4 sm:flex-row sm:items-start sm:justify-between sm:px-5">
<div>
<h2 id="patrol-objectives-title" class="text-base font-semibold text-base-content">
What Patrol should keep true
What Patrol is looking after
</h2>
<p class="mt-1 max-w-3xl text-sm text-muted">
Describe the outcome. Patrol chooses a cheap local signal, wakes the model only when
@@ -224,6 +226,28 @@ export const PatrolObjectivesPanel: Component = () => {
</div>
}
>
<div
class={`mb-4 rounded-lg border px-4 py-3 ${
protectionSummary().tone === 'success'
? 'border-emerald-200 bg-emerald-50/70 dark:border-emerald-900 dark:bg-emerald-950/20'
: protectionSummary().tone === 'warning'
? 'border-amber-200 bg-amber-50/70 dark:border-amber-900 dark:bg-amber-950/20'
: 'border-border-subtle bg-surface-alt/40'
}`}
aria-live="polite"
>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<p class="text-sm font-semibold text-base-content">
{protectionSummary().headline}
</p>
<Show when={protectionSummary().paused > 0}>
<MetadataBadge tone="neutral" size="xs" shape="rounded">
{protectionSummary().paused} paused
</MetadataBadge>
</Show>
</div>
<p class="mt-1 text-xs leading-5 text-muted">{protectionSummary().detail}</p>
</div>
<Show
when={objectives().length > 0}
fallback={
@@ -0,0 +1,141 @@
import { For, Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import CheckCircleIcon from 'lucide-solid/icons/circle-check';
import RefreshIcon from 'lucide-solid/icons/refresh-cw';
import { getPatrolAttention, type AttentionItem } from '@/api/patrolAttention';
import { Button } from '@/components/shared/Button';
import { formatRelativeTime } from '@/utils/format';
import { getVerifiedPatrolReceiptSummary, isVerifiedPatrolReceipt } from './patrolHomePresentation';
const RECEIPT_LIMIT = 6;
const formatRecentWorkError = (error: unknown): string =>
error instanceof Error ? error.message : 'Verified work could not be loaded.';
export function PatrolRecentWorkPanel() {
const [items, setItems] = createSignal<AttentionItem[]>([]);
const [loading, setLoading] = createSignal(true);
const [error, setError] = createSignal('');
const receipts = createMemo(() =>
items().filter(isVerifiedPatrolReceipt).slice(0, RECEIPT_LIMIT),
);
const load = async (quiet = false) => {
if (!quiet) setLoading(true);
try {
const response = await getPatrolAttention('resolved', 1, 50);
setItems(response.data);
setError('');
} catch (cause) {
setError(formatRecentWorkError(cause));
} finally {
if (!quiet) setLoading(false);
}
};
onMount(() => {
void load();
const refresh = () => {
if (document.visibilityState === 'visible') void load(true);
};
const timer = window.setInterval(refresh, 30_000);
document.addEventListener('visibilitychange', refresh);
onCleanup(() => {
window.clearInterval(timer);
document.removeEventListener('visibilitychange', refresh);
});
});
return (
<section
class="overflow-hidden rounded-lg border border-border bg-surface"
aria-labelledby="patrol-recent-work-title"
>
<div class="flex items-start justify-between gap-3 border-b border-border px-4 py-4 sm:px-5">
<div>
<h2 id="patrol-recent-work-title" class="text-base font-semibold text-base-content">
Recently handled
</h2>
<p class="mt-1 max-w-3xl text-sm leading-5 text-muted">
Concise receipts appear only after Patrol has verified the outcome.
</p>
</div>
<Button
variant="secondary"
size="sm"
class="gap-1.5"
onClick={() => void load()}
disabled={loading()}
aria-label="Refresh recently handled work"
>
<RefreshIcon
class={`h-4 w-4 ${loading() ? 'motion-safe:animate-spin' : ''}`}
aria-hidden="true"
/>
<span class="hidden sm:inline">Refresh</span>
</Button>
</div>
<div aria-live="polite">
<Show when={error()}>
{(message) => (
<div class="m-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/30 dark:text-red-200">
<p class="font-semibold">Verified work is unavailable</p>
<p class="mt-1 text-xs leading-5">{message()}</p>
</div>
)}
</Show>
<Show
when={!loading() || receipts().length > 0}
fallback={<p class="px-4 py-8 text-center text-sm text-muted">Loading verified work</p>}
>
<Show
when={receipts().length > 0}
fallback={
<div class="px-6 py-8 text-center">
<CheckCircleIcon class="mx-auto h-8 w-8 text-muted" aria-hidden="true" />
<h3 class="mt-3 text-sm font-semibold text-base-content">No verified work yet</h3>
<p class="mx-auto mt-1 max-w-lg text-xs leading-5 text-muted">
Patrol will record a receipt here after an issue is resolved and its expected
outcome is confirmed.
</p>
</div>
}
>
<ol class="divide-y divide-border" aria-label="Verified Patrol receipts">
<For each={receipts()}>
{(item) => (
<li class="flex items-start gap-3 px-4 py-3 sm:px-5">
<CheckCircleIcon
class="mt-0.5 h-5 w-5 shrink-0 text-emerald-500"
aria-hidden="true"
/>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
<span class="text-xs font-semibold uppercase tracking-wide text-emerald-700 dark:text-emerald-300">
Verified
</span>
<span class="text-xs text-muted">
{formatRelativeTime(item.lastObservedAt, { compact: true })}
</span>
</div>
<p class="mt-1 text-sm font-semibold text-base-content">{item.title}</p>
<p class="mt-1 text-xs leading-5 text-muted">
{getVerifiedPatrolReceiptSummary(item)}
</p>
<p class="mt-1 truncate text-xs font-medium text-base-content">
{item.subjectResourceName}
</p>
</div>
</li>
)}
</For>
</ol>
</Show>
</Show>
</div>
</section>
);
}
export default PatrolRecentWorkPanel;
@@ -204,14 +204,16 @@ describe('PatrolAttentionWorkbench', () => {
renderWorkbench();
expect(
await screen.findByRole('heading', { name: 'Nothing needs your attention' }),
await screen.findByRole('heading', { name: 'Nothing needs you right now' }),
).toBeInTheDocument();
expect(
screen.getByText(/current operational evaluation has no active items/i),
).toBeInTheDocument();
expect(screen.getByText(/current operational lifecycle evaluation/i)).toBeInTheDocument();
expect(screen.queryByText(/trust score/i)).not.toBeInTheDocument();
expect(screen.queryByText(/auto-resolved/i)).not.toBeInTheDocument();
});
it('keeps the fixed attention states available through one responsive shared control', async () => {
it('keeps lifecycle filters out of the primary decision surface', async () => {
const attentionSummary = summary({
activeCount: 1,
openCount: 1,
@@ -224,26 +226,75 @@ describe('PatrolAttentionWorkbench', () => {
apiMocks.getList.mockResolvedValue(listResponse([item()], attentionSummary));
renderWorkbench();
const activeButton = await screen.findByRole('button', { name: 'Active 1' });
const stateGroup = screen.getByRole('group', { name: 'Attention state' });
expect(stateGroup).toHaveClass('hidden', 'xl:inline-flex');
expect(activeButton).toHaveAttribute('aria-pressed', 'true');
expect(await screen.findByRole('heading', { name: 'Needs you' })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Open Disk pressure on Database VM' }),
).toBeInTheDocument();
expect(screen.queryByRole('group', { name: 'Attention state' })).not.toBeInTheDocument();
expect(screen.queryByRole('combobox', { name: 'Attention state' })).not.toBeInTheDocument();
expect(apiMocks.getList).toHaveBeenCalledWith('active');
});
const stateSelect = screen.getByRole('combobox', { name: 'Attention state' });
expect(stateSelect.parentElement).toHaveClass('xl:hidden');
expect(stateSelect).toHaveValue('active');
expect(within(stateSelect).getByRole('option', { name: 'Recent resolved 5' })).toHaveValue(
'resolved',
it('does not interrupt assisted mode for safe eligible work', async () => {
const safeWork = item({
availableActions: [
{
targetResourceId: 'pve:vm:101',
capability: 'cleanup_disk',
kind: 'cleanup',
label: 'Clean safe temporary files',
mode: 'execute',
risk: 'low',
approval: 'not-required',
eligibility: 'eligible',
reasons: [],
evidenceIds: ['evidence-1'],
expectedPostcondition: 'Disk use returns below the warning threshold.',
verificationPolicy: 'disk-pressure',
requiresApproval: false,
},
],
verificationState: 'pending',
});
apiMocks.getList.mockResolvedValue(
listResponse([safeWork], summary({ activeCount: 1, openCount: 1, calm: false })),
);
fireEvent.change(stateSelect, { target: { value: 'resolved' } });
render(() => (
<Router>
<Route
path="/patrol"
component={() => (
<PatrolAttentionWorkbench autonomyLevel="assisted" autonomyLocked={false} />
)}
/>
</Router>
));
await waitFor(() => expect(apiMocks.getList).toHaveBeenLastCalledWith('resolved'));
expect(stateSelect).toHaveValue('resolved');
expect(screen.getByRole('button', { name: 'Recent resolved 5' })).toHaveAttribute(
'aria-pressed',
'true',
expect(
await screen.findByRole('heading', { name: 'Nothing needs you right now' }),
).toBeInTheDocument();
expect(
screen.getByText(/1 other current issue does not require a decision/i),
).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Open Disk pressure/ })).not.toBeInTheDocument();
});
it('keeps a long decision queue compact until the operator asks for all of it', async () => {
const decisions = Array.from({ length: 7 }, (_, index) =>
item({ id: `record-${index + 1}`, title: `Decision ${index + 1}` }),
);
apiMocks.getList.mockResolvedValue(
listResponse(decisions, summary({ activeCount: 7, openCount: 7, calm: false })),
);
renderWorkbench();
const showAll = await screen.findByRole('button', { name: 'Show all 7 decisions' });
expect(screen.getAllByRole('button', { name: /Open Decision/ })).toHaveLength(5);
fireEvent.click(showAll);
expect(screen.getAllByRole('button', { name: /Open Decision/ })).toHaveLength(7);
expect(screen.getByRole('button', { name: 'Show fewer decisions' })).toBeInTheDocument();
});
it('opens deepest typed evidence, protection, and timeline detail from one queue item', async () => {
@@ -371,7 +422,7 @@ describe('PatrolAttentionWorkbench', () => {
});
expect(screen.getByText(/protection context is incomplete/i)).toBeInTheDocument();
expect(
screen.queryByRole('heading', { name: 'Nothing needs your attention' }),
screen.queryByRole('heading', { name: 'Nothing needs you right now' }),
).not.toBeInTheDocument();
});
@@ -69,7 +69,7 @@ describe('PatrolIntelligenceHeader', () => {
expect(headerSource).toContain('Open Patrol settings');
expect(headerSource).toContain("settingsTabPath('system-ai-patrol')");
expect(headerSource).not.toContain('Patrol schedule and model settings');
expect(headerSource).toContain('Run Patrol');
expect(headerSource).toContain('Check now');
});
it('turns provider-blocked manual run controls into setup actions', () => {
@@ -92,7 +92,9 @@ describe('PatrolIntelligenceHeader', () => {
});
it('keeps primary Patrol actions touch-sized on phones without inflating desktop chrome', () => {
expect(headerSource).toContain('flex min-h-11 items-center gap-2 rounded-md bg-blue-600');
expect(headerSource).toContain(
'flex min-h-11 items-center gap-2 rounded-md border border-border bg-surface',
);
expect(headerSource).toContain(
'flex min-h-11 items-center gap-2 rounded-md border border-border',
);
@@ -228,10 +230,10 @@ describe('PatrolIntelligenceHeader', () => {
external: false,
},
});
expect(headerSource).toContain('showAutonomyPlanBillingAction');
expect(headerSource).toContain("autonomyAvailability().kind === 'plan_locked'");
expect(headerSource).toContain('CreditCardIcon');
expect(headerSource).toContain('{autonomyAvailability().actionLabel}');
expect(headerSource).toContain('commercialSurfacesHidden: true');
expect(headerSource).not.toContain('showAutonomyPlanBillingAction');
expect(headerSource).not.toContain('CreditCardIcon');
expect(headerSource).not.toContain('Plans & Billing');
const hiddenUpgradePresentation = getPatrolAutonomyAvailabilityPresentation({
autoFixLocked: true,
@@ -0,0 +1,113 @@
import { cleanup, render, screen, waitFor } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { AttentionItem, AttentionListResponse } from '@/api/patrolAttention';
const apiMocks = vi.hoisted(() => ({ getList: vi.fn() }));
vi.mock('@/api/patrolAttention', async (importOriginal) => {
const original = await importOriginal<typeof import('@/api/patrolAttention')>();
return {
...original,
getPatrolAttention: (...args: unknown[]) => apiMocks.getList(...args),
};
});
import { PatrolRecentWorkPanel } from '../PatrolRecentWorkPanel';
const receipt = (overrides: Partial<AttentionItem> = {}): AttentionItem => ({
id: 'receipt-1',
operationalRecordId: 'record-1',
subjectResourceId: 'docker:host/service/jellyfin',
subjectResourceName: 'Jellyfin',
kind: 'availability',
title: 'Jellyfin playback restored',
plainLanguageSummary: 'Playback recovered.',
severity: 'warning',
state: 'resolved',
firstObservedAt: '2026-08-14T07:00:00Z',
lastObservedAt: '2026-08-14T07:05:00Z',
evidenceFreshness: 'fresh',
evidenceCompleteness: 'complete',
relatedResources: [],
availableActions: [
{
targetResourceId: 'docker:host/service/jellyfin',
capability: 'restart_service',
kind: 'restart',
label: 'Restart Jellyfin',
mode: 'execute',
risk: 'low',
approval: 'not-required',
eligibility: 'eligible',
reasons: [],
evidenceIds: ['evidence-1'],
expectedPostcondition: 'Playback health checks return to normal.',
verificationPolicy: 'availability',
requiresApproval: false,
},
],
verificationState: 'succeeded',
...overrides,
});
const response = (items: AttentionItem[]): AttentionListResponse => ({
data: items,
summary: {
activeCount: 0,
openCount: 0,
acknowledgedCount: 0,
suppressedCount: 0,
uncertainCount: 0,
resolvedCount: items.length,
calm: true,
coverageState: 'current',
evaluatedAt: '2026-08-14T07:05:00Z',
},
meta: { page: 1, limit: 50, total: items.length, totalPages: items.length ? 1 : 0 },
});
describe('PatrolRecentWorkPanel', () => {
beforeEach(() => apiMocks.getList.mockReset());
afterEach(cleanup);
it('shows only successfully verified resolved work as receipts', async () => {
apiMocks.getList.mockResolvedValue(
response([
receipt(),
receipt({ id: 'failed', verificationState: 'failed' }),
receipt({ id: 'open', state: 'open' }),
]),
);
render(() => <PatrolRecentWorkPanel />);
expect(await screen.findByText('Playback recovered.')).toBeInTheDocument();
expect(screen.getAllByText('Verified')).toHaveLength(1);
expect(apiMocks.getList).toHaveBeenCalledWith('resolved', 1, 50);
});
it('does not imply successful work when there is no verified receipt', async () => {
apiMocks.getList.mockResolvedValue(response([receipt({ verificationState: 'unknown' })]));
render(() => <PatrolRecentWorkPanel />);
expect(
await screen.findByRole('heading', { name: 'No verified work yet' }),
).toBeInTheDocument();
expect(screen.queryByText('Verified')).not.toBeInTheDocument();
});
it('keeps the last truthful receipts visible if a background refresh fails', async () => {
apiMocks.getList.mockResolvedValueOnce(response([receipt()]));
apiMocks.getList.mockRejectedValueOnce(new Error('relay unavailable'));
render(() => <PatrolRecentWorkPanel />);
await screen.findByText('Playback recovered.');
document.dispatchEvent(new Event('visibilitychange'));
await waitFor(() =>
expect(screen.getByText('Verified work is unavailable')).toBeInTheDocument(),
);
expect(screen.getByText('Playback recovered.')).toBeInTheDocument();
});
});
@@ -11,17 +11,13 @@ describe('patrol commercial boundary', () => {
expect(patrolIntelligenceBannersSource).toContain('!state.showBlockedBanner()');
expect(patrolIntelligenceBannersSource).toContain('!state.shouldShowPatrolSetupOnly()');
expect(patrolIntelligenceHeaderSource).toContain('presentationPolicyHidesUpgradePrompts');
expect(patrolIntelligenceHeaderSource).toContain('presentationPolicyHidesCommercialSurfaces');
expect(patrolIntelligenceHeaderSource).toContain('!presentationPolicyHidesUpgradePrompts()');
expect(patrolIntelligenceHeaderSource).toContain('!commercialSurfacesHidden()');
expect(patrolIntelligenceHeaderSource).toContain('state.autoFixLocked()');
expect(patrolIntelligenceHeaderSource).toContain(
"autonomyAvailability().kind === 'runtime_locked'",
);
expect(patrolIntelligenceHeaderSource).toContain('showAutonomyPlanBillingAction');
expect(patrolIntelligenceHeaderSource).toContain(
"autonomyAvailability().kind === 'plan_locked'",
);
expect(patrolIntelligenceHeaderSource).toContain('commercialSurfacesHidden: true');
expect(patrolIntelligenceHeaderSource).not.toContain('showAutonomyPlanBillingAction');
expect(patrolAutonomyAvailabilitySource).toContain('Plans & Billing');
expect(patrolAutonomyAvailabilitySource).toContain('input.upgradePromptsHidden');
expect(patrolIntelligenceHeaderSource).toContain('getPatrolAutonomyAvailabilityPresentation');
@@ -36,7 +32,8 @@ describe('patrol commercial boundary', () => {
expect(patrolIntelligenceHeaderSource).toContain(
'<Show when={shouldShowAutonomyActionColumn()}>',
);
expect(patrolIntelligenceHeaderSource).toContain('showProBadge');
expect(patrolIntelligenceHeaderSource).not.toContain('showProBadge');
expect(patrolIntelligenceHeaderSource).not.toContain('Plans & Billing');
expect(patrolIntelligenceHeaderSource).not.toContain('const isProLocked = () =>');
expect(patrolIntelligenceHeaderSource).not.toContain('requires Pulse Pro');
});
@@ -0,0 +1,163 @@
import { describe, expect, it } from 'vitest';
import type { PatrolObjective } from '@/api/patrol';
import type { AttentionItem } from '@/api/patrolAttention';
import {
getPatrolAttentionDecisionReason,
getPatrolObjectiveProtectionSummary,
getVerifiedPatrolReceiptSummary,
isVerifiedPatrolReceipt,
partitionPatrolAttention,
} from '../patrolHomePresentation';
const attentionItem = (overrides: Partial<AttentionItem> = {}): AttentionItem => ({
id: 'attention-1',
operationalRecordId: 'record-1',
subjectResourceId: 'docker:host/service/jellyfin',
subjectResourceName: 'Jellyfin',
kind: 'availability',
title: 'Jellyfin playback unavailable',
plainLanguageSummary: 'Playback health checks are failing.',
severity: 'warning',
state: 'open',
firstObservedAt: '2026-08-14T07:00:00Z',
lastObservedAt: '2026-08-14T07:05:00Z',
evidenceFreshness: 'fresh',
evidenceCompleteness: 'complete',
relatedResources: [],
availableActions: [
{
targetResourceId: 'docker:host/service/jellyfin',
capability: 'restart_service',
kind: 'restart',
label: 'Restart Jellyfin',
mode: 'execute',
risk: 'low',
approval: 'not-required',
eligibility: 'eligible',
reasons: [],
evidenceIds: ['evidence-1'],
expectedPostcondition: 'Playback health checks return to normal.',
verificationPolicy: 'availability',
requiresApproval: false,
},
],
verificationState: 'pending',
...overrides,
});
const objective = (overrides: Partial<PatrolObjective> = {}): PatrolObjective => ({
id: 'objective-1',
brief: 'Keep Jellyfin playback healthy',
scope: { resource_ids: [] },
status: 'active',
coverage: {
state: 'covered',
reason_code: 'observer_current',
summary: 'Watching playback health locally.',
},
revision: 1,
created_at: '2026-08-14T07:00:00Z',
updated_at: '2026-08-14T07:00:00Z',
...overrides,
});
describe('patrol home presentation', () => {
it('keeps every active issue visible as a decision in watch-only and ask-first modes', () => {
const item = attentionItem();
expect(getPatrolAttentionDecisionReason(item, 'monitor')).toMatch(/will not make changes/i);
expect(getPatrolAttentionDecisionReason(item, 'approval')).toMatch(/requires your approval/i);
expect(getPatrolAttentionDecisionReason(item, 'full', true)).toMatch(/will not make changes/i);
});
it('keeps safe eligible work quiet in assisted and full modes', () => {
const item = attentionItem();
expect(getPatrolAttentionDecisionReason(item, 'assisted')).toBeNull();
expect(getPatrolAttentionDecisionReason(item, 'full')).toBeNull();
expect(partitionPatrolAttention([item], 'full')).toEqual({ needsUser: [], quiet: [item] });
});
it('interrupts for approval, trust gaps, failed verification, and missing actions', () => {
const approvalRequired = attentionItem({
availableActions: [
{
...attentionItem().availableActions[0],
approval: 'required',
requiresApproval: true,
},
],
});
const staleEvidence = attentionItem({ evidenceFreshness: 'stale' });
const failedVerification = attentionItem({ verificationState: 'failed' });
const noAction = attentionItem({ availableActions: [] });
for (const item of [approvalRequired, staleEvidence, failedVerification, noAction]) {
expect(getPatrolAttentionDecisionReason(item, 'full')).not.toBeNull();
}
});
it('does not interrupt when at least one eligible governed path can proceed', () => {
const safeAction = attentionItem().availableActions[0];
const item = attentionItem({
availableActions: [
{ ...safeAction, capability: 'safe_cleanup' },
{
...safeAction,
capability: 'expand_disk',
approval: 'required',
requiresApproval: true,
},
],
});
expect(getPatrolAttentionDecisionReason(item, 'assisted')).toBeNull();
});
it('summarizes protected, uncovered, and paused objectives without overstating health', () => {
expect(getPatrolObjectiveProtectionSummary([])).toMatchObject({
headline: 'No outcomes protected yet',
tone: 'neutral',
});
expect(getPatrolObjectiveProtectionSummary([objective()])).toMatchObject({
headline: 'Protecting 1 outcome',
covered: 1,
needsCoverage: 0,
tone: 'success',
});
expect(
getPatrolObjectiveProtectionSummary([
objective(),
objective({
id: 'objective-2',
coverage: {
state: 'uncovered',
reason_code: 'observer_missing',
summary: 'Monitoring is being prepared.',
},
}),
objective({ id: 'objective-3', status: 'paused' }),
]),
).toMatchObject({
headline: '1 of 2 outcomes protected',
covered: 1,
needsCoverage: 1,
paused: 1,
tone: 'warning',
});
});
it('creates receipts only from resolved records with successful verification', () => {
const resolved = attentionItem({
state: 'resolved',
verificationState: 'succeeded',
plainLanguageSummary: 'Playback health checks returned to normal.',
});
expect(isVerifiedPatrolReceipt(resolved)).toBe(true);
expect(isVerifiedPatrolReceipt(attentionItem({ state: 'resolved' }))).toBe(false);
expect(getVerifiedPatrolReceiptSummary(resolved)).toBe(
'Playback health checks returned to normal.',
);
});
});
@@ -0,0 +1,186 @@
import type { PatrolAutonomyLevel, PatrolObjective } from '@/api/patrol';
import type { AttentionItem } from '@/api/patrolAttention';
export interface PatrolAutonomyExperience {
label: string;
summary: string;
needsYouDescription: string;
quietWorkDescription: string;
}
export const PATROL_AUTONOMY_EXPERIENCE: Record<PatrolAutonomyLevel, PatrolAutonomyExperience> = {
monitor: {
label: 'Watch only',
summary: 'Patrol watches your infrastructure. You decide what happens next.',
needsYouDescription:
'Patrol reports current issues here because Watch only never makes infrastructure changes.',
quietWorkDescription: 'Patrol continues watching these issues without making changes.',
},
approval: {
label: 'Ask first',
summary: 'Patrol investigates and prepares fixes, then waits for your approval.',
needsYouDescription:
'These issues need your decision before Patrol can make a change or close the loop.',
quietWorkDescription: 'Patrol continues investigating while it waits for the decisions above.',
},
assisted: {
label: 'Safe auto-fix',
summary: 'Patrol handles policy-allowed safe work and asks when risk or evidence needs you.',
needsYouDescription:
'Patrol only interrupts when policy, risk, missing evidence, or verification requires your decision.',
quietWorkDescription:
'Other current issues can proceed within the safe auto-fix policy without a decision from you.',
},
full: {
label: 'Autopilot',
summary:
'Patrol handles allowed work in the background and interrupts only when it cannot proceed safely.',
needsYouDescription:
'Patrol only interrupts when it is blocked, lacks trustworthy evidence, or still requires approval.',
quietWorkDescription:
'Other current issues do not require a decision under Autopilot and remain visible in Alerts.',
},
};
export interface PatrolAttentionDecision {
item: AttentionItem;
reason: string;
}
const itemHasTrustGap = (item: AttentionItem): boolean =>
item.state === 'unknown' ||
item.evidenceFreshness !== 'fresh' ||
item.evidenceCompleteness !== 'complete' ||
item.verificationState === 'failed' ||
item.verificationState === 'unknown';
export function getPatrolAttentionDecisionReason(
item: AttentionItem,
autonomyLevel: PatrolAutonomyLevel,
autonomyLocked = false,
): string | null {
const effectiveLevel = autonomyLocked ? 'monitor' : autonomyLevel;
if (effectiveLevel === 'monitor') {
return 'Watch only will not make changes. Review this issue and decide what should happen.';
}
if (effectiveLevel === 'approval') {
return item.availableActions.length > 0
? 'Patrol has an action to review, but Ask first requires your approval.'
: 'Patrol cannot complete this issue without your decision.';
}
if (itemHasTrustGap(item)) {
return item.verificationState === 'failed' || item.verificationState === 'unknown'
? 'Patrol could not verify the outcome and needs you to review the evidence.'
: 'Patrol does not have trustworthy enough evidence to proceed automatically.';
}
const eligibleActions = item.availableActions.filter(
(action) => action.eligibility === 'eligible',
);
if (eligibleActions.length === 0) {
return item.availableActions.length > 0
? 'Available actions are blocked or ineligible, so Patrol needs your decision.'
: 'Patrol has no governed action for this issue and needs you to decide what happens next.';
}
const canProceedWithoutAnotherDecision = eligibleActions.some(
(action) =>
action.approval === 'not-required' ||
action.approval === 'granted' ||
(!action.requiresApproval && action.approval !== 'denied'),
);
if (!canProceedWithoutAnotherDecision) {
return 'A governed action still requires your approval or a policy decision.';
}
return null;
}
export function partitionPatrolAttention(
items: AttentionItem[],
autonomyLevel: PatrolAutonomyLevel,
autonomyLocked = false,
): { needsUser: PatrolAttentionDecision[]; quiet: AttentionItem[] } {
const needsUser: PatrolAttentionDecision[] = [];
const quiet: AttentionItem[] = [];
for (const item of items) {
const reason = getPatrolAttentionDecisionReason(item, autonomyLevel, autonomyLocked);
if (reason) {
needsUser.push({ item, reason });
} else {
quiet.push(item);
}
}
return { needsUser, quiet };
}
export interface PatrolObjectiveProtectionSummary {
active: number;
covered: number;
needsCoverage: number;
paused: number;
headline: string;
detail: string;
tone: 'success' | 'warning' | 'neutral';
}
export function getPatrolObjectiveProtectionSummary(
objectives: PatrolObjective[],
): PatrolObjectiveProtectionSummary {
const activeObjectives = objectives.filter((objective) => objective.status === 'active');
const covered = activeObjectives.filter(
(objective) => objective.coverage.state === 'covered',
).length;
const needsCoverage = activeObjectives.length - covered;
const paused = objectives.filter((objective) => objective.status === 'paused').length;
if (activeObjectives.length === 0) {
return {
active: 0,
covered: 0,
needsCoverage: 0,
paused,
headline: 'No outcomes protected yet',
detail:
paused > 0
? `${paused} ${paused === 1 ? 'objective is' : 'objectives are'} paused.`
: 'Add the outcomes you want Patrol to keep true.',
tone: 'neutral',
};
}
if (needsCoverage === 0) {
return {
active: activeObjectives.length,
covered,
needsCoverage: 0,
paused,
headline: `Protecting ${covered} ${covered === 1 ? 'outcome' : 'outcomes'}`,
detail: 'Every active objective has current background coverage.',
tone: 'success',
};
}
return {
active: activeObjectives.length,
covered,
needsCoverage,
paused,
headline: `${covered} of ${activeObjectives.length} ${activeObjectives.length === 1 ? 'outcome' : 'outcomes'} protected`,
detail: `${needsCoverage} ${needsCoverage === 1 ? 'objective needs' : 'objectives need'} monitoring coverage before Patrol can rely on them.`,
tone: 'warning',
};
}
export function isVerifiedPatrolReceipt(item: AttentionItem): boolean {
return item.state === 'resolved' && item.verificationState === 'succeeded';
}
export function getVerifiedPatrolReceiptSummary(item: AttentionItem): string {
return item.plainLanguageSummary;
}
@@ -693,12 +693,9 @@ describe('AIIntelligence entitlement gating', () => {
expect(patrolControl.queryByRole('button', { name: 'Ask first Pro' })).toBeNull();
expect(patrolControl.queryByRole('button', { name: 'Safe auto-fix Pro' })).toBeNull();
expect(patrolControl.queryByRole('button', { name: 'Autopilot Pro' })).toBeNull();
expect(screen.getByRole('link', { name: 'Plans & Billing' })).toHaveAttribute(
'href',
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
);
expect(screen.queryByRole('link', { name: 'Plans & Billing' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'View plans' })).not.toBeInTheDocument();
expect(operationsLoopAnchor?.parentElement).toBe(patrolControlAnchor);
expect(patrolControlAnchor).toContainElement(operationsLoopAnchor);
expect(screen.queryByTestId('patrol-current-work')).not.toBeInTheDocument();
expect(screen.getByText('Current Patrol issues appear here.')).toBeInTheDocument();
expect(
@@ -711,7 +708,7 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.queryByText('Duty trail')).not.toBeInTheDocument();
expect(screen.queryByText('External agents')).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Pulse MCP' })).not.toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Run Patrol' }).length).toBeGreaterThan(0);
expect(screen.getAllByRole('button', { name: 'Check now' }).length).toBeGreaterThan(0);
});
it('consumes Patrol control route handoffs without loading legacy loop status', async () => {
@@ -809,7 +806,7 @@ describe('AIIntelligence entitlement gating', () => {
});
expect(providerSettingsLinks.length).toBeGreaterThan(0);
expect(providerSettingsLinks[0]).toHaveAttribute('href', '/settings/pulse-intelligence/patrol');
expect(screen.queryByRole('button', { name: /Run Patrol/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Check now/i })).not.toBeInTheDocument();
expect(triggerPatrolRunMock).not.toHaveBeenCalled();
});
@@ -843,7 +840,7 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.getByText('Patrol model warning')).toBeInTheDocument();
});
expect(screen.queryByText('Patrol readiness warning')).not.toBeInTheDocument();
const runButtons = screen.getAllByRole('button', { name: /Run Patrol/i });
const runButtons = screen.getAllByRole('button', { name: /Check now/i });
for (const button of runButtons) {
expect(button).not.toBeDisabled();
}
@@ -863,7 +860,7 @@ describe('AIIntelligence entitlement gating', () => {
getCorrelationsMock.mockImplementation(() => new Promise(() => {}));
fireEvent.click(screen.getAllByRole('button', { name: /Run Patrol/i })[0]);
fireEvent.click(screen.getAllByRole('button', { name: /Check now/i })[0]);
await waitFor(() => {
expect(triggerPatrolRunMock).toHaveBeenCalled();
@@ -888,7 +885,7 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.getByRole('heading', { name: 'Open work' })).toBeInTheDocument();
});
fireEvent.click(screen.getAllByRole('button', { name: /Run Patrol/i })[0]);
fireEvent.click(screen.getAllByRole('button', { name: /Check now/i })[0]);
await waitFor(() => {
expect(notificationErrorMock).toHaveBeenCalledWith(
@@ -909,7 +906,7 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.queryByText('Patrol setup warning')).not.toBeInTheDocument();
expect(screen.queryByText('Patrol readiness issue')).not.toBeInTheDocument();
expect(screen.queryByText('Patrol readiness warning')).not.toBeInTheDocument();
for (const button of screen.getAllByRole('button', { name: /Run Patrol/i })) {
for (const button of screen.getAllByRole('button', { name: /Check now/i })) {
expect(button).not.toBeDisabled();
}
});
@@ -1073,10 +1070,7 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.queryByRole('button', { name: 'Ask first Pro' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Safe auto-fix Pro' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Autopilot Pro' })).toBeNull();
expect(screen.getByRole('link', { name: 'Plans & Billing' })).toHaveAttribute(
'href',
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
);
expect(screen.queryByRole('link', { name: 'Plans & Billing' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'View plans' })).not.toBeInTheDocument();
expect(screen.queryByText('Unlock Patrol mode')).not.toBeInTheDocument();
expect(screen.queryByText('More Patrol modes')).not.toBeInTheDocument();
@@ -1540,7 +1534,9 @@ describe('AIIntelligence entitlement gating', () => {
await waitFor(() => {
expect(getPatrolStatusMock).toHaveBeenCalled();
expect(screen.getByText('Patrol enabled')).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: 'Patrol is looking after your infrastructure' }),
).toBeInTheDocument();
});
expect(screen.queryByText('Patrol quickstart exhausted')).not.toBeInTheDocument();
@@ -1614,7 +1610,9 @@ describe('AIIntelligence entitlement gating', () => {
await waitFor(() => {
expect(getPatrolStatusMock).toHaveBeenCalled();
expect(screen.getByText('Patrol enabled')).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: 'Patrol is looking after your infrastructure' }),
).toBeInTheDocument();
});
expect(screen.queryByText(/Patrol quickstart/i)).not.toBeInTheDocument();
@@ -1899,7 +1897,7 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.queryByText('Runtime issue')).not.toBeInTheDocument();
expect(screen.queryByText(/regressed \d+×/)).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Runtime issue/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Run Patrol' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Check now' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Open Patrol settings' })).not.toBeInTheDocument();
expect(screen.queryByText(/Automation:/)).not.toBeInTheDocument();
const patrolControlAnchor = document.getElementById(PATROL_CONTROL_ANCHOR);
@@ -1929,7 +1927,8 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.queryByTestId('patrol-status-bar')).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Open work' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Active' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Needs you' })).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();
expect(screen.queryByRole('button', { name: 'Details' })).not.toBeInTheDocument();
@@ -3567,9 +3567,9 @@ describe('frontend resource type boundaries', () => {
expect(patrolIntelligenceBannersSource).toContain('!presentationPolicyHidesUpgradePrompts()');
expect(patrolIntelligenceBannersSource).toContain('state.licenseRequired()');
expect(patrolIntelligenceHeaderSource).toContain('presentationPolicyHidesUpgradePrompts');
expect(patrolIntelligenceHeaderSource).toContain('presentationPolicyHidesCommercialSurfaces');
expect(patrolIntelligenceHeaderSource).toContain('!presentationPolicyHidesUpgradePrompts()');
expect(patrolIntelligenceHeaderSource).toContain('!commercialSurfacesHidden()');
expect(patrolIntelligenceHeaderSource).toContain('commercialSurfacesHidden: true');
expect(patrolIntelligenceHeaderSource).not.toContain('Plans & Billing');
expect(patrolIntelligenceHeaderSource).toContain(
"from '@/components/shared/FilterButtonGroup'",
);
@@ -22,7 +22,9 @@ PATROL_PAGE_AND_STATE_EXACT_FILES = [
"frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx",
"frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts",
"frontend-modern/src/features/patrol/__tests__/PatrolObjectivesPanel.test.tsx",
"frontend-modern/src/features/patrol/__tests__/PatrolRecentWorkPanel.test.tsx",
"frontend-modern/src/features/patrol/__tests__/patrolControlPresentation.test.ts",
"frontend-modern/src/features/patrol/__tests__/patrolHomePresentation.test.ts",
"frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts",
"frontend-modern/src/features/patrol/__tests__/patrolRunAcceptance.test.ts",
"frontend-modern/src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts",