mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Rework Patrol around outcomes and quiet autonomy
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user