diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 4bdd3ac88d..b423c07bfa 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -1164,6 +1164,7 @@ the canonical monitored-system blocked payload. and the Patrol runtime-remediation destination shared with the AI settings endpoint, so summary actions, run-history runtime-failure actions, and runtime-finding actions may reuse the governed provider-settings route while still presenting that destination to Patrol operators as Patrol provider configuration instead of generic `AI Settings` copy and the Patrol route-shell destination itself, so the thin page shell at `frontend-modern/src/pages/AIIntelligence.tsx` may continue to bridge the shared AI-runtime payload boundary while exposing `/patrol` as the canonical product route and keeping retired `/ai` browser entry points unregistered and the Patrol route-shell accessibility boundary, so brand icons in `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx` stay decorative when the same heading already exposes visible Patrol text, preventing duplicate accessible names such as `Pulse Patrol Patrol` + and the Patrol autonomy selector boundary, so the header composes the shared `frontend-modern/src/components/shared/FilterButtonGroup.tsx` segmented primitive for the `Monitor` / `Investigate` / `Remediate` presentation while the API contract remains the sole owner of accepted autonomy values, license-required rejection shape, and the monitor-only clamp 9. Route frontend API-client parsed error propagation, API-error-status fallback handling, allowed-status handling, custom status-specific error handling, command-trigger success envelope handling, shared response parsing pipelines, missing-resource lookup handling, metadata CRUD routing, stream event consumption, response status, collection normalization, scalar payload coercion, and structured error normalization through canonical shared helpers under `frontend-modern/src/api/` Assistant chat stream workflow-state payloads are part of this same frontend API-client boundary. `workflow_state` events must keep `phase`, @@ -5334,6 +5335,12 @@ owners must not rely on that 402 as normal control flow for stale local state: when the current entitlement locks safe remediation, they submit `monitor` even if older persisted settings or a previous entitlement left `approval`, `assisted`, or `full` in memory. +The Patrol header presentation for that API boundary must compose the shared +`frontend-modern/src/components/shared/FilterButtonGroup.tsx` segmented +selector: the endpoint contract still owns the accepted autonomy values and +license-required response shape, while the frontend owns only the option +mapping and entitlement-derived disabled state. Local Patrol selector styling +must not become a second source of truth for the API's monitor-only clamp. The reporting transport contract now also carries an optional narrative interpretation layer alongside the deterministic data surface. The Go-side `pkg/reporting.MetricReportRequest` gains optional `Narrator` and diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 4e51acbce6..2f0d4f90a4 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -2416,6 +2416,12 @@ small leading status-dot glyph used by filter options. Page and feature surfaces must not copy the chip-dot `` factory or import the legacy `PageControls` deck for resource-list filtering; those drift checks live in `shared-template-registry.json` and run through `shared-template-audit.mjs`. +Segmented selectors are registry-backed too. `FilterButtonGroup` owns the +settings, prominent, compact, and equal segmented selector shells, including +active-button tone, disabled-option behavior, pressed-state semantics, +compact labels, and horizontal scroll treatment through the shared +shell/state/model split. Settings and compact feature surfaces must compose +that primitive instead of copying active-button selector styling locally. Chart visibility display actions are registry-backed too. `ChartVisibilityToggleButton` owns the `Show charts` / `Hide charts` label, pressed-state, title, icon, and toolbar action styling for summary-bearing diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 8870bcf3c8..3358b1d973 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -597,6 +597,13 @@ surface must render any Patrol readiness provider, model, and summary carried by the failure object, and keep the provider-settings action immediately available before the operator chooses to open the Assistant handoff. +The Patrol autonomy selector in that header must compose the shared +`frontend-modern/src/components/shared/FilterButtonGroup.tsx` segmented +variant instead of rebuilding a local active-button group. Patrol owns only +the domain options (`Monitor`, `Investigate`, `Remediate`), entitlement locks, +and the `full`-as-remediate presentation mapping; the shared primitive owns +the equal-width segmented shell, pressed-state semantics, disabled-option +behavior, and active/inactive selector styling. That same Patrol-owned presentation rule also applies to the findings empty state: `frontend-modern/src/components/AI/FindingsPanel.tsx` must not treat `0 active findings` as equivalent to "your infrastructure looks healthy" when diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index cc0169b7eb..ef0b06bc84 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -240,6 +240,25 @@ "scripts/shared-template-audit.mjs" ] }, + { + "id": "filter-button-group-shell", + "category": "segmented-selector", + "summary": "Settings and compact feature segmented selectors must compose FilterButtonGroup instead of recreating active-button group styling locally.", + "canonical": { + "path": "src/components/shared/FilterButtonGroup.tsx", + "export": "FilterButtonGroup" + }, + "requiredConsumers": [ + { "path": "src/components/Settings/GeneralSettingsPanel.tsx" }, + { "path": "src/components/Settings/ReportingPanel.tsx" }, + { "path": "src/features/patrol/PatrolIntelligenceHeader.tsx" } + ], + "proof": [ + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "src/components/shared/FilterButtonGroup.test.tsx", + "scripts/shared-template-audit.mjs" + ] + }, { "id": "chart-visibility-toggle-button", "category": "display-toggle", @@ -517,6 +536,27 @@ "src/components/shared/SharedPrimitives.guardrails.test.ts", "scripts/shared-template-audit.mjs" ] + }, + { + "id": "filter-button-group-local-segmented-control-styles", + "category": "segmented-selector", + "summary": "Settings and feature surfaces must not copy the compact active-button segmented-control styling owned by FilterButtonGroup.", + "canonical": { + "path": "src/components/shared/filterButtonGroupModel.ts", + "export": "getFilterButtonGroupButtonClass" + }, + "scopes": ["src/components/Settings", "src/features", "src/pages"], + "extensions": [".tsx"], + "allPatterns": [ + "flex items-center bg-base rounded-md p-1 border shadow-inner", + "flex-1 py-1.5 px-2 text-xs font-semibold rounded-md transition-all duration-200" + ], + "legacyReason": "Retired migration debt. Settings and compact feature segmented selectors must use FilterButtonGroup; local active-button selector styling forks are not allowed.", + "allowedPaths": [], + "proof": [ + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "scripts/shared-template-audit.mjs" + ] } ], "requiredPatternGuards": [ diff --git a/frontend-modern/src/components/shared/FilterButtonGroup.test.tsx b/frontend-modern/src/components/shared/FilterButtonGroup.test.tsx index c6db620bde..d559b5a480 100644 --- a/frontend-modern/src/components/shared/FilterButtonGroup.test.tsx +++ b/frontend-modern/src/components/shared/FilterButtonGroup.test.tsx @@ -30,6 +30,7 @@ describe('FilterButtonGroup', () => { expect(filterButtonGroupModelSource).toContain('getFilterButtonGroupButtonClass'); expect(filterButtonGroupModelSource).toContain('getFilterButtonGroupCompactLabel'); expect(filterButtonGroupModelSource).toContain("prominent: 'grid grid-cols-1 gap-2'"); + expect(filterButtonGroupModelSource).toContain('segmented:'); expect(filterButtonGroupModelSource).toContain('compact:'); expect(filterButtonGroupModelSource).toContain('inline-flex items-center gap-1'); }); @@ -168,4 +169,30 @@ describe('FilterButtonGroup', () => { expect(visualLabel).toHaveClass('inline-flex'); expect(visualLabel).toHaveClass('whitespace-nowrap'); }); + + it('supports equal-width segmented controls for compact feature settings', () => { + render(() => ( + undefined} + variant="segmented" + /> + )); + + const group = screen.getByRole('group', { name: 'Patrol autonomy level' }); + const activeButton = within(group).getByRole('button', { name: 'Monitor' }); + const disabledButton = within(group).getByRole('button', { name: 'Investigate' }); + + expect(activeButton.className).toContain('flex-1'); + expect(activeButton.className).toContain('bg-surface'); + expect(activeButton.className).toContain('text-blue-600'); + expect(disabledButton).toBeDisabled(); + expect(disabledButton.className).toContain('opacity-50'); + }); }); diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index d2340f2732..2d72f0f55f 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -154,6 +154,7 @@ import reportingPanelSource from '@/components/Settings/ReportingPanel.tsx?raw'; import rolesPanelSource from '@/components/Settings/RolesPanel.tsx?raw'; import updatesSettingsPanelSource from '@/components/Settings/UpdatesSettingsPanel.tsx?raw'; import userAssignmentsPanelSource from '@/components/Settings/UserAssignmentsPanel.tsx?raw'; +import patrolIntelligenceHeaderSource from '@/features/patrol/PatrolIntelligenceHeader.tsx?raw'; import filterBarSource from '@/components/shared/FilterBar/FilterBar.tsx?raw'; import filterChipSource from '@/components/shared/FilterBar/FilterChip.tsx?raw'; import addFilterMenuSource from '@/components/shared/FilterBar/AddFilterMenu.tsx?raw'; @@ -187,7 +188,54 @@ describe('shared primitive guardrails', () => { expect(rawTableUsers).toEqual(['./PulseDataGrid.tsx']); }); - it('routes canonical settings segmented selectors through FilterButtonGroup', () => { + it('routes canonical settings and feature segmented selectors through FilterButtonGroup', () => { + const registry = JSON.parse(sharedTemplateRegistrySource) as { + rules?: Array<{ + id: string; + canonical?: { path?: string; export?: string }; + requiredConsumers?: Array<{ path?: string }>; + }>; + patternGuards?: Array<{ + id: string; + canonical?: { path?: string; export?: string }; + allPatterns?: string[]; + scopes?: string[]; + allowedPaths?: string[]; + }>; + }; + const registeredRule = registry.rules?.find( + (rule) => rule.id === 'filter-button-group-shell', + ); + const registeredGuard = registry.patternGuards?.find( + (guard) => guard.id === 'filter-button-group-local-segmented-control-styles', + ); + + expect(registeredRule?.canonical?.path).toBe( + 'src/components/shared/FilterButtonGroup.tsx', + ); + expect(registeredRule?.canonical?.export).toBe('FilterButtonGroup'); + expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([ + 'src/components/Settings/GeneralSettingsPanel.tsx', + 'src/components/Settings/ReportingPanel.tsx', + 'src/features/patrol/PatrolIntelligenceHeader.tsx', + ]); + expect(registeredGuard?.canonical?.path).toBe( + 'src/components/shared/filterButtonGroupModel.ts', + ); + expect(registeredGuard?.canonical?.export).toBe('getFilterButtonGroupButtonClass'); + expect(registeredGuard?.allPatterns).toEqual([ + 'flex items-center bg-base rounded-md p-1 border shadow-inner', + 'flex-1 py-1.5 px-2 text-xs font-semibold rounded-md transition-all duration-200', + ]); + expect(registeredGuard?.allowedPaths ?? []).toHaveLength(0); + expect(registeredGuard?.scopes).toEqual( + expect.arrayContaining([ + 'src/components/Settings', + 'src/features', + 'src/pages', + ]), + ); + expect(filterButtonGroupSource).toContain('useFilterButtonGroupState'); expect(filterButtonGroupSource).toContain('getFilterButtonGroupClass'); expect(filterButtonGroupSource).toContain('getFilterButtonGroupButtonClass'); @@ -200,6 +248,7 @@ describe('shared primitive guardrails', () => { expect(filterButtonGroupStateSource).toContain('props.disabled || option.disabled'); expect(filterButtonGroupStateSource).toContain('props.onChange(option.value)'); expect(filterButtonGroupModelSource).toContain("prominent: 'grid grid-cols-1 gap-2'"); + expect(filterButtonGroupModelSource).toContain('segmented:'); expect(filterButtonGroupModelSource).toContain('touch-scroll'); expect(filterButtonGroupModelSource).toContain('getFilterButtonGroupButtonClass'); expect(filterButtonGroupModelSource).toContain('getFilterButtonGroupCompactLabel'); @@ -215,6 +264,15 @@ describe('shared primitive guardrails', () => { expect(reportingPanelSource).toContain('variant="prominent"'); expect(reportingPanelSource).not.toContain('getReportingToggleButtonClass'); expect(reportingPanelSource).not.toContain(''); + expect(patrolIntelligenceHeaderSource).toContain('FilterButtonGroup'); + expect(patrolIntelligenceHeaderSource).toContain('variant="segmented"'); + expect(patrolIntelligenceHeaderSource).toContain('selectedAutonomyLevel'); + expect(patrolIntelligenceHeaderSource).not.toContain( + 'flex items-center bg-base rounded-md p-1 border shadow-inner', + ); + expect(patrolIntelligenceHeaderSource).not.toContain( + 'flex-1 py-1.5 px-2 text-xs font-semibold rounded-md transition-all duration-200', + ); }); it('keeps AI model picker labels route-aware for gateway providers', () => { diff --git a/frontend-modern/src/components/shared/filterButtonGroupModel.ts b/frontend-modern/src/components/shared/filterButtonGroupModel.ts index d4f26009df..ada83f8ee8 100644 --- a/frontend-modern/src/components/shared/filterButtonGroupModel.ts +++ b/frontend-modern/src/components/shared/filterButtonGroupModel.ts @@ -13,7 +13,12 @@ export interface FilterOption { disabled?: boolean; } -export type FilterButtonGroupVariant = 'default' | 'settings' | 'compact' | 'prominent'; +export type FilterButtonGroupVariant = + | 'default' + | 'settings' + | 'compact' + | 'prominent' + | 'segmented'; export type FilterButtonGroupOptionTone = | 'default' | 'info' @@ -39,6 +44,8 @@ const groupClassByVariant: Record = { compact: 'inline-flex items-center gap-1 bg-surface-hover rounded-md p-0.5 ring-1 ring-border-subtle overflow-x-auto scrollbar-hide', prominent: 'grid grid-cols-1 gap-2', + segmented: + 'flex items-center gap-1 rounded-md border border-border bg-base p-1 shadow-inner overflow-x-auto scrollbar-hide', }; const labelClassByVariant: Partial> = { @@ -109,6 +116,16 @@ export function getFilterButtonGroupButtonClass( ].join(' '); } + if (variant === 'segmented') { + return [ + 'flex-1 min-h-8 px-2 py-1.5 text-xs font-semibold rounded-md transition-all whitespace-nowrap outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2', + active + ? 'bg-surface text-blue-600 shadow-sm dark:text-blue-400' + : 'text-muted hover:text-base-content hover:bg-surface-hover', + disabled ? 'opacity-50 cursor-not-allowed' : '', + ].join(' '); + } + return [ 'flex flex-1 justify-center sm:flex-none sm:justify-start items-center gap-2 px-3 sm:px-4 py-2.5 sm:py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2', active diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx index 4ddb8e2486..71fe625338 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx @@ -10,6 +10,8 @@ import { PageHeader } from '@/components/shared/PageHeader'; import { Toggle, TogglePrimitive } from '@/components/shared/Toggle'; import { CountdownTimer } from '@/components/patrol'; import { FormSelect } from '@/components/shared/FormSelect'; +import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup'; +import type { PatrolAutonomyLevel } from '@/api/patrol'; import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy'; import { formatRelativeTime } from '@/utils/format'; import { groupModelsByProvider } from '@/utils/patrolFormat'; @@ -21,6 +23,8 @@ import { PATROL_PROVIDER_SETTINGS_ACTION } from '@/utils/patrolRuntimeActions'; import type { PatrolConfigurationFailureInput } from './patrolInvestigationContextModel'; import type { PatrolIntelligenceState } from './usePatrolIntelligenceState'; +type PatrolSelectableAutonomyLevel = Exclude; + const isNonEmptyConfigurationDetail = (value?: string | null): value is string => Boolean(value?.trim()); @@ -73,6 +77,34 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState if (!model || models.length === 0) return false; return !models.some((candidate) => candidate.id === model); }); + const selectedAutonomyLevel = createMemo(() => { + const level = state.autonomyLevel(); + return level === 'full' ? 'assisted' : level; + }); + const getAutonomyOptionTitle = (level: PatrolSelectableAutonomyLevel) => { + const isProLocked = () => state.autoFixLocked() && level !== 'monitor'; + if (!presentationPolicyHidesUpgradePrompts() && isProLocked()) { + return level === 'approval' + ? 'Investigation is not enabled on this plan' + : 'Safe remediation workflows are not enabled on this plan'; + } + return undefined; + }; + const autonomyLevelOptions = createMemo[]>(() => [ + { value: 'monitor', label: 'Monitor' }, + { + value: 'approval', + label: 'Investigate', + disabled: state.autoFixLocked(), + title: getAutonomyOptionTitle('approval'), + }, + { + value: 'assisted', + label: 'Remediate', + disabled: state.autoFixLocked(), + title: getAutonomyOptionTitle('assisted'), + }, + ]); return (
@@ -288,41 +320,15 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
-
- - {(level) => { - const isProLocked = () => - state.autoFixLocked() && (level === 'approval' || level === 'assisted'); - const isDisabled = () => !state.patrolEnabledLocal() || isProLocked(); - const isActive = () => - level === 'assisted' - ? state.autonomyLevel() === 'assisted' || - state.autonomyLevel() === 'full' - : state.autonomyLevel() === level; - - return ( - - ); - }} - -
+ state.handleAutonomyChange(level)} + variant="segmented" + />
Investigation and safe remediation workflows are not enabled on this plan. diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index ee77c8f328..63454a6bfd 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -3542,6 +3542,16 @@ describe('frontend resource type boundaries', () => { expect(patrolIntelligenceHeaderSource).toContain( '!presentationPolicyHidesUpgradePrompts() && state.alertAnalysisLocked()', ); + expect(patrolIntelligenceHeaderSource).toContain( + "from '@/components/shared/FilterButtonGroup'", + ); + expect(patrolIntelligenceHeaderSource).toContain('variant="segmented"'); + expect(patrolIntelligenceHeaderSource).not.toContain( + 'flex items-center bg-base rounded-md p-1 border shadow-inner', + ); + expect(patrolIntelligenceHeaderSource).not.toContain( + 'flex-1 py-1.5 px-2 text-xs font-semibold rounded-md transition-all duration-200', + ); expect(appSource).not.toContain("import('./pages/Operations')"); expect(appSource).not.toContain('