From daccf6c4d7dd6c191bf339da94e9fa01ba0a453a Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 14 Aug 2026 13:08:12 +0100 Subject: [PATCH] Fold the action ledger into Patrol navigation --- .../v6/internal/subsystems/ai-runtime.md | 8 +++ .../v6/internal/subsystems/api-contracts.md | 21 +++++--- .../v6/internal/subsystems/cloud-paid.md | 18 ++++--- .../subsystems/frontend-primitives.md | 9 ++++ .../subsystems/patrol-intelligence.md | 6 +++ .../internal/subsystems/unified-resources.md | 13 +++-- frontend-modern/browser-verification.json | 50 +++++++++---------- frontend-modern/src/AppLayout.tsx | 32 ++++++------ .../src/__tests__/App.architecture.test.ts | 7 ++- .../src/__tests__/AppLayout.test.tsx | 37 +++++++++++++- .../shared/__tests__/MobileNavBar.test.tsx | 3 +- .../mobileNavBarModel.branchcov0712.test.ts | 14 +++--- .../components/shared/mobileNavBarModel.ts | 12 ++--- .../patrol/PatrolIntelligenceSurface.tsx | 46 +++++++++++++++++ frontend-modern/src/pages/Actions.tsx | 4 +- .../pages/__tests__/AIIntelligence.test.tsx | 4 ++ .../src/routing/__tests__/navigation.test.ts | 9 +++- .../src/utils/assistantPageContext.ts | 2 +- 18 files changed, 210 insertions(+), 85 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 74b11ffa5..d87d9ed25 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -3178,6 +3178,14 @@ query...`, and `Reading storage...` before streamed tool arguments are standalone, but `frontend-modern/src/AppLayout.tsx` must treat them as decorative inside tabs so the announced tab name comes from product chrome and meaningful badge text rather than icon title duplication. Scoped + governed-operation history is subordinate to Patrol in that chrome: + `/actions` remains a stable route and Assistant page context, but it must not + render as a peer utility tab. While that route is open, the shell keeps + Patrol selected so the user remains inside one operational workspace; this + navigation relationship must not merge Activity history state into + Assistant drawer state or allow the drawer to become an alternate action + review client. + Scoped approval handoffs sourced from Patrol, active alerts, or alert incident timelines must render as source-named investigation handoffs in the drawer instead of generic dashboard briefs. Source-owned handoff helpers may attach diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 8327b7860..29b117d07 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -551,12 +551,16 @@ and may label the default queue `Open work`, but those labels are chrome over this existing status/finding/approval contract; they are not new API fields, prompt payloads, or alternate current-work schemas. Shell navigation may derive a content-free `Patrol` open-work count from the -existing Patrol findings endpoint plus live investigation-fix approvals, -de-duplicated by finding/approval target id. That shell count is not a new API -route or status field, must not include finding IDs, approval IDs, resource -names, commands, prompt text, or model output in visible chrome, and must not -replace the detail-owning Patrol findings, approval, run-history, or status -routes. +existing Patrol findings endpoint and durable action-inbox pending count. When +there are pending governed actions, the shell badge prioritizes that universal +approval count and label; otherwise it may show the active Patrol finding +count. This is presentation over existing read models, not a new API route or +status field. It must not include finding IDs, approval IDs, resource names, +commands, prompt text, or model output in visible chrome, and must not replace +the detail-owning Patrol findings, durable action inbox, approval, run-history, +or status routes. The Patrol surface may expose a compact `Activity history` +handoff with the same count, but the durable `/actions` route continues to own +the complete record list and review controls. The authenticated `GET /api/agent/patrol-control/status` projection exposes the same content-free starter evidence as count-only fields (`operationsLoopStarterCount`, `assistantOperationsLoopStarterCount`, `patrolOperationsLoopStarterCount`, @@ -2506,6 +2510,11 @@ a new API state machine, queue contract, or verification-accounting field. instance-scoped style. 8. Route unified-agent installer and binary download headers through `internal/api/unified_agent.go` and `internal/api/contract_test.go` together. Unified-agent BINARY downloads must keep the canonical `X-Checksum-Sha256` plus `X-Signature-Ed25519` contract for updater clients whether the binary is served locally or proxied from the matching GitHub release, instead of leaving callers to infer trust from source location alone. The served install-script endpoints (GET /install.sh and /install.ps1) are governed differently and have NO GitHub fallback at all: they serve the locally bundled AGENT installer or fail closed with 503. The agent installer is a per-build artifact bundled into every release tarball and Docker image, not a release asset, so the endpoint must never fetch the top-level GitHub install.sh release asset (the SERVER installer, which rejects the agent wizard's --url / --token-file, issue #1470). It attaches the base64-encoded `X-Signature-SSHSIG` header when the local detached signatures are present and omits it when they are not; a present-but-unsigned local agent installer is still served, because the agent install path (curl piped into bash) does not verify these headers, so correctness of the served script outranks signature presence. 9. Route canonical AI intelligence summary and resource-intelligence reads through `frontend-modern/src/api/ai.ts`, `frontend-modern/src/stores/aiIntelligence.ts`, `frontend-modern/src/stores/aiIntelligenceSummaryModel.ts`, `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts`, `frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx`, the Patrol-owned section files under `frontend-modern/src/features/patrol/`, `frontend-modern/src/pages/AIIntelligence.tsx`, `internal/api/ai_handlers.go`, and `internal/api/contract_test.go` together so the store normalization owner, runtime hook, feature shell, Current work workspace, section owners, route shell, and backend payload stay aligned on one governed surface, including the canonical recent-changes slice + while keeping the universal governed-operation ledger as one subordinate + handoff rather than a second Patrol implementation: the surface may show + the durable action inbox's content-free pending count and link to + `/actions`, but action rows, policy decisions, mutations, and verified + outcomes remain owned by the action inbox and review route and the Patrol work-group inputs, where the browser may group current approvals, failed actions, failed checks, recurring active issues, and overdue scheduled protection only from API-owned Patrol status, run history, diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 6a5b112d7..74ce3cc30 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -605,11 +605,14 @@ or other self-hosted uncapped continuity plans. ## Extension Points -The authenticated app shell exposes `/actions` as a global utility route on -desktop and responsive navigation. `App.tsx` and `AppLayout.tsx` only provide -route/navigation ownership; action authority and result truth remain in the -unified-resources and api-contracts projections. Navigation proof is owned by -the App/AppLayout, routing, and desktop Actions journey tests. +The authenticated app shell keeps `/actions` as the stable global Activity +history route, reached from Patrol rather than exposed as a peer desktop or +responsive utility tab. `App.tsx` and `AppLayout.tsx` only provide route and +navigation ownership: Patrol remains selected while Activity history is open, +and action authority and result truth remain in the unified-resources and +api-contracts projections. This information-architecture choice is independent +of hosted entitlement, billing, and acquisition state. Navigation proof is +owned by the App/AppLayout, routing, and desktop Actions journey tests. 1. Add or change limits through `pkg/licensing/` 2. Add or change hosted entitlement issuance through `internal/cloudcp/entitlements/service.go` @@ -973,7 +976,10 @@ the App/AppLayout, routing, and desktop Actions journey tests. destination remains `Patrol` over the same `/patrol` route; it must not alter hosted org bootstrap, entitlement loading, or commercial posture loading. A Patrol open-work badge in that shell may consume only the - existing Patrol findings and live-approval read model after authentication; + existing Patrol findings and durable action-inbox pending count after + authentication. The subordinate `/actions` Activity history route keeps + Patrol selected in primary chrome and must not reappear as a commercial or + peer utility destination; it must not read hosted billing state, trigger commercial-posture loading, affect organization visibility, or become an upgrade/acquisition cue. The same primary platform navigation must remember the last in-tab route diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 519c56d56..f848fc079 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -5710,6 +5710,15 @@ the matching Open or History subtab from server-authored lifecycle state, and removes the query when the dialog closes. Feature pages may summarize action context, but they must not recreate approve, reject, run, progress, or outcome controls outside the shared Actions review. +The Actions ledger is no longer a peer top-level navigation destination. +Patrol is the primary operations home and exposes the ledger as `Activity +history`, including a pending-review count and a route-backed handoff. The +canonical `/actions` route remains stable for exact action deep links and +universal audit records originating from Patrol, Assistant, MCP, or manual +controls. While that subordinate route is open, desktop and mobile navigation +keep Patrol selected and the browser title identifies `Activity history`; +removing the standalone navigation entry must not remove the shared review +dialog, route, API client, or durable action identity. ### Protection posture presentation boundary diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index c33eec403..b1bc33d66 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -596,6 +596,12 @@ attention`, `approval needed`, `outcome verified`, `no active work`) instead verification needed, failed fix, or setup attention, but it must not expose generic `detected`, `review finding`, raw loop-state, investigation-status, investigation-outcome, or confidence badges on the default Patrol page. + Patrol is also the daily entry point for governed operations that did not + originate in Patrol. Its compact `Activity history` handoff exposes the + universal pending-review count and opens the canonical action ledger without + copying ledger rows or action controls into Patrol. The ledger remains an + auditable subordinate route, not a second top-level inbox; `/actions` deep + links stay stable and keep Patrol selected in shared navigation. Patrol approval and remediation actions own approval, denial, reapproval, review, and Assistant handoff semantics, but their visible action chrome must compose the shared `Button` primitive for success, warning-solid, primary, diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index b651597c8..b5d732921 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -785,15 +785,20 @@ container inventory table. activity must not infer related resources in the frontend. ## Extension Points -The desktop Product Trust projection is owned at -`frontend-modern/src/features/actions/` with the route shell in -`frontend-modern/src/pages/Actions.tsx`. It must consume durable +The global Product Trust projection is owned at +`frontend-modern/src/features/actions/` with the stable `/actions` route shell +in `frontend-modern/src/pages/Actions.tsx`, presented to users as `Activity +history`. Patrol is the primary operational home and may expose a compact +pending-count handoff into this route, but the ledger must not reappear as a +peer top-level utility tab and Patrol must not duplicate its rows or controls. +The route must consume durable `ActionAuditRecord.plan.policyDecision` and `result.actionResultV2` without deriving policy authority or collapsing execution, verification, and recovery into one outcome. Docker lifecycle controls may create a canonical plan and open this shared review, but may not auto-approve, auto-execute, or use a second-click local confirmation. Proof is owned by the colocated action tests, -`DockerNativeTables.test.tsx`, and desktop journeys 81 and 83. +the routing and app-shell tests, `DockerNativeTables.test.tsx`, and desktop +journeys 81 and 83. Patrol findings may retain bounded action status and safety context, but exact typed action ids hand off to the route-backed Actions review through `frontend-modern/src/features/actions/actionRouting.ts`. The `action` query diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 3a872a988..27998b848 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,22 +1,25 @@ { "version": 1, - "base_sha": "772ff3087d60ac9ee9b69ddf42bcc76a8eae5be0", - "verified_at": "2026-08-14T11:45:19Z", + "base_sha": "48fac73ff020491c1731c281d0c69b874c27f71b", + "verified_at": "2026-08-14T12:03:35Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/shared/useDialogState.ts", - "frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx", - "frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx", - "frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts" + "frontend-modern/src/AppLayout.tsx", + "frontend-modern/src/components/shared/mobileNavBarModel.ts", + "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx", + "frontend-modern/src/pages/Actions.tsx", + "frontend-modern/src/utils/assistantPageContext.ts" ], "content_sha256": { - "frontend-modern/src/components/shared/useDialogState.ts": "3566700d8a8e72f3962204f12096912b84fb3bf089a4b181c1d9326ba85a1306", - "frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx": "272c6cb9c975cb346aa96082e804ad2eb08419d7ab2157429b901ba8b2f67a94", - "frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx": "ba86e9637aca3abd63de515d5e98a8881ddfc4ed7b308aaa651926fa5eb48db9", - "frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "24ac4a67d8e97bc39867238c34b2cf4deede69ccf671eef1ffc47c739a25db4a" + "frontend-modern/src/AppLayout.tsx": "91e6aa7723872ec61437da7d3851fe82be89e707f53c66a3c3ff74fa7851671a", + "frontend-modern/src/components/shared/mobileNavBarModel.ts": "bccadf7522c9e7affa06ecebd816c8963295bd40ce2ac122a022d3662e1a5e7d", + "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "b6eaffbb9aa1e0b9e69a5245d0e2e4bce5b9c88df353d207db095189df6e82c1", + "frontend-modern/src/pages/Actions.tsx": "878397bb4571fc714684e35474acc6a60220498b4ac256c4bd57f39e41533ba5", + "frontend-modern/src/utils/assistantPageContext.ts": "7943a778ee4d4e1fb56c0a09778fffc8402d528da2e336713b1ff93e110acf6f" }, "routes": [ - "/patrol" + "/patrol", + "/actions" ], "viewports": [ { @@ -29,21 +32,18 @@ } ], "states": [ - "Watch only with direct objective shown as Watching in background", - "Watch only with Jellyfin and Frigate proxy objectives shown as Useful signal only", - "objective creation dialog open with the outcome textarea focused", - "Autopilot acknowledgement modal before and after acknowledgement checkbox", - "Autopilot active with canonical server state and no year-one expiry", - "Autopilot revoked and Patrol returned to Watch only", - "narrow Patrol layout with no horizontal overflow" + "Patrol desktop navigation with one pending universal action", + "Patrol Activity history handoff with pending-review badge", + "Activity history open queue with Patrol selected in desktop navigation", + "Activity history open queue with Patrol selected in mobile navigation", + "Patrol and Activity history at 390 pixels without horizontal overflow" ], "interactions": [ - "opened Add objective and confirmed autofocus", - "closed objective dialog with Escape and confirmed focus return", - "opened Autopilot acknowledgement and enabled activation with the acknowledgement checkbox", - "activated Autopilot through the real UI and confirmed the server-derived mode", - "revoked Autopilot through the real UI", - "switched Patrol back to Watch only", - "resized to 390 by 844 and confirmed body and document scroll widths stayed at 390" + "opened Activity history from the Patrol page", + "confirmed the stable /actions route and Activity history browser title", + "confirmed no Actions destination exists in desktop or mobile navigation", + "confirmed Patrol remains selected while Activity history is open", + "returned to Patrol through mobile navigation", + "resized to 390 by 844 and confirmed document width stayed at 390" ] } diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index aaf087a45..d9ad26210 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -3,7 +3,6 @@ import { Portal } from 'solid-js/web'; import type { JSX } from 'solid-js'; import { useLocation, useNavigate } from '@solidjs/router'; import BellIcon from 'lucide-solid/icons/bell'; -import ListChecksIcon from 'lucide-solid/icons/list-checks'; import SettingsIcon from 'lucide-solid/icons/settings'; import Maximize2Icon from 'lucide-solid/icons/maximize-2'; import Minimize2Icon from 'lucide-solid/icons/minimize-2'; @@ -298,7 +297,7 @@ export function AppLayout(props: AppLayoutProps) { vmware: 'vSphere', standalone: 'Machines', alerts: 'Alerts', - actions: 'Actions', + actions: 'Activity history', ai: 'Patrol', settings: 'Settings', }; @@ -440,8 +439,12 @@ export function AppLayout(props: AppLayoutProps) { clearHeaderHideTimeout(); }); - const getActiveTabDesktop = () => getActiveTabForPath(location.pathname); - const getActiveTabMobile = () => getActiveTabForPath(location.pathname); + const getNavigationActiveTab = () => { + const active = getActiveTabForPath(location.pathname); + return active === 'actions' ? 'ai' : active; + }; + const getActiveTabDesktop = getNavigationActiveTab; + const getActiveTabMobile = getNavigationActiveTab; const assistantPageContext = createMemo(() => getAssistantPageContext(location.pathname)); const openAssistantFromLauncher = () => { restoreAssistantLauncherFocus = true; @@ -482,6 +485,12 @@ export function AppLayout(props: AppLayoutProps) { if (count <= 0) return undefined; return `${count} active attention ${count === 1 ? 'item' : 'items'}`; }); + const patrolNavigationCount = createMemo( + () => (actionApprovalBadge()?.count ?? patrolAttentionCount()) || undefined, + ); + const patrolNavigationCountLabel = createMemo( + () => actionApprovalBadge()?.label ?? patrolAttentionCountLabel(), + ); // Platform/runtime nav is resource-admitted. A platform or runtime lens only // appears when the support manifest says the surface is supported and the @@ -590,25 +599,14 @@ export function AppLayout(props: AppLayoutProps) { breakdown, icon: BellIcon, }, - { - id: 'actions', - label: 'Actions', - route: '/actions', - tooltip: 'Review proposed changes and verified outcomes', - badge: null, - count: actionApprovalBadge()?.count, - countLabel: actionApprovalBadge()?.label, - breakdown: undefined, - icon: ListChecksIcon, - }, { id: 'ai', label: 'Patrol', route: '/patrol', tooltip: 'Review active operational attention and recent Patrol checks', badge: null, - count: patrolAttentionCount() > 0 ? patrolAttentionCount() : undefined, - countLabel: patrolAttentionCountLabel(), + count: patrolNavigationCount(), + countLabel: patrolNavigationCountLabel(), breakdown: undefined, icon: PulsePatrolLogo, }, diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index 56649d1c9..f9b30bd15 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -525,11 +525,14 @@ describe('App architecture', () => { expect(appLayoutSource).not.toContain("'/operations', '/patrol', '/ai'"); expect(appLayoutSource).toContain("route: '/patrol',"); expect(appLayoutSource).toContain("label: 'Patrol'"); + expect(appLayoutSource).not.toContain("id: 'actions',"); + expect(appLayoutSource).not.toContain("label: 'Actions',"); + expect(appLayoutSource).toContain("return active === 'actions' ? 'ai' : active;"); expect(appLayoutSource).toContain( "tooltip: 'Review active operational attention and recent Patrol checks'", ); expect(appLayoutSource).toContain('const patrolAttentionCount = createMemo('); - expect(appLayoutSource).toContain('countLabel: patrolAttentionCountLabel()'); + expect(appLayoutSource).toContain('countLabel: patrolNavigationCountLabel()'); expect(appLayoutSource).not.toContain("label: 'Needs Attention'"); expect(appLayoutSource).not.toContain("route: '/operations',"); expect(appLayoutSource).not.toContain('props.connected()'); @@ -716,7 +719,7 @@ describe('App architecture', () => { expect(runtimeHomeSource).not.toContain('aiIntelligenceStore'); expect(runtimeHomeSource).not.toContain('patrolOpenWork'); expect(appLayoutSource).toContain("label: 'Patrol'"); - expect(appLayoutSource).toContain('countLabel: patrolAttentionCountLabel()'); + expect(appLayoutSource).toContain('countLabel: patrolNavigationCountLabel()'); }); it('keeps licensed application branding inside the authenticated shell bootstrap', () => { diff --git a/frontend-modern/src/__tests__/AppLayout.test.tsx b/frontend-modern/src/__tests__/AppLayout.test.tsx index f16452f9d..33811ab68 100644 --- a/frontend-modern/src/__tests__/AppLayout.test.tsx +++ b/frontend-modern/src/__tests__/AppLayout.test.tsx @@ -28,6 +28,9 @@ function setViewportWidth(width: number) { const patrolAttentionMockState = vi.hoisted(() => ({ activeCount: 0, })); +const actionInboxMockState = vi.hoisted(() => ({ + pendingActionCount: 0, +})); vi.mock('@/stores/patrolAttention', () => ({ patrolAttentionStore: { @@ -37,6 +40,14 @@ vi.mock('@/stores/patrolAttention', () => ({ }, })); +vi.mock('@/stores/actionInbox', () => ({ + actionInboxStore: { + get pendingActionCount() { + return actionInboxMockState.pendingActionCount; + }, + }, +})); + vi.mock('@/routing/routePreload', () => ({ preloadRouteModule: vi.fn(() => Promise.resolve()), })); @@ -118,6 +129,7 @@ const renderLayout = ( + )); }; @@ -128,6 +140,7 @@ describe('AppLayout navigation icons', () => { window.history.replaceState({}, '', '/settings/infrastructure'); resetPrimaryNavigationRouteMemory(); patrolAttentionMockState.activeCount = 0; + actionInboxMockState.pendingActionCount = 0; clearRuntimeBranding(); aiChatStore.close(); aiChatStore.setEnabled(true); @@ -160,7 +173,7 @@ describe('AppLayout navigation icons', () => { expect(systemGroup).toBeTruthy(); const desktopTabs = within(systemGroup as HTMLElement).getAllByRole('tab'); - expect(desktopTabs).toHaveLength(4); + expect(desktopTabs).toHaveLength(3); desktopTabs.forEach((tab) => { expect(tab.querySelector('svg')).toBeTruthy(); }); @@ -177,7 +190,7 @@ describe('AppLayout navigation icons', () => { expect(within(systemGroup as HTMLElement).queryByRole('tab', { name: 'Patrol P' })).toBeNull(); const mobileNav = screen.getByRole('navigation', { name: 'Mobile navigation' }); - ['alerts', 'actions', 'ai'].forEach((tabId) => { + ['alerts', 'ai'].forEach((tabId) => { const button = mobileNav.querySelector(`[data-tab-id="${tabId}"]`); expect(button).toBeTruthy(); expect(button?.querySelector('svg')).toBeTruthy(); @@ -219,6 +232,26 @@ describe('AppLayout navigation icons', () => { expect(within(mobileNav).queryByText('Needs Attention')).toBeNull(); }); + it('routes universal pending operations and the subordinate activity route through Patrol', () => { + actionInboxMockState.pendingActionCount = 3; + renderLayout([], '/actions'); + + const desktopNav = screen.getByRole('tablist', { name: 'Primary navigation' }); + const systemGroup = desktopNav.querySelector('[aria-label="System"]'); + expect(systemGroup).toBeTruthy(); + const patrolTab = within(systemGroup as HTMLElement).getByRole('tab', { + name: 'Patrol: 3 actions await approval', + }); + expect(patrolTab.className).toContain('text-blue-600'); + expect(within(systemGroup as HTMLElement).queryByRole('tab', { name: 'Actions' })).toBeNull(); + + const mobileNav = screen.getByRole('navigation', { name: 'Mobile navigation' }); + expect( + within(mobileNav).getByRole('button', { name: 'Patrol: 3 actions await approval' }), + ).toHaveAttribute('aria-current', 'page'); + expect(document.title).toContain('Activity history'); + }); + it('shows platform and runtime lens tabs with supported infrastructure evidence', () => { renderLayout([ makeResource({ id: 'agent-1', type: 'agent', platformType: 'agent' }), diff --git a/frontend-modern/src/components/shared/__tests__/MobileNavBar.test.tsx b/frontend-modern/src/components/shared/__tests__/MobileNavBar.test.tsx index 9a2a665b6..2694b9fd7 100644 --- a/frontend-modern/src/components/shared/__tests__/MobileNavBar.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/MobileNavBar.test.tsx @@ -96,7 +96,6 @@ describe('MobileNavBar', () => { utilityTabs={() => [ makeUtility('settings', 'Settings'), makeUtility('ai', 'Patrol'), - makeUtility('actions', 'Actions'), makeUtility('alerts', 'Alerts'), ]} onPrimaryClick={() => {}} @@ -110,7 +109,7 @@ describe('MobileNavBar', () => { Array.from(fixedRail?.querySelectorAll('button[data-tab-id]') ?? []).map((button) => button.getAttribute('data-tab-id'), ), - ).toEqual(['proxmox', 'alerts', 'actions', 'ai', 'more']); + ).toEqual(['proxmox', 'alerts', 'ai', 'more']); const more = screen.getByRole('button', { name: 'More navigation' }); expect(more).toHaveAttribute('aria-haspopup', 'menu'); diff --git a/frontend-modern/src/components/shared/__tests__/mobileNavBarModel.branchcov0712.test.ts b/frontend-modern/src/components/shared/__tests__/mobileNavBarModel.branchcov0712.test.ts index 7cac8562c..5baec3d6e 100644 --- a/frontend-modern/src/components/shared/__tests__/mobileNavBarModel.branchcov0712.test.ts +++ b/frontend-modern/src/components/shared/__tests__/mobileNavBarModel.branchcov0712.test.ts @@ -114,22 +114,22 @@ describe('mobileNavBarModel.branchcov2', () => { it('uses countLabel when count > 0 (countLabel ?? right-side fallback NOT taken)', () => { const tab = makeUtilityTab({ - id: 'actions', - label: 'Actions', + id: 'ai', + label: 'Patrol', count: 12, countLabel: '12 pending', }); - expect(getMobileNavTabAriaLabel(tab)).toBe('Actions: 12 pending'); + expect(getMobileNavTabAriaLabel(tab)).toBe('Patrol: 12 pending'); }); it('synthesizes "N items" when countLabel is absent (countLabel ?? right arm)', () => { const tab = makeUtilityTab({ - id: 'actions', - label: 'Actions', + id: 'ai', + label: 'Patrol', count: 12, countLabel: undefined, }); - expect(getMobileNavTabAriaLabel(tab)).toBe('Actions: 12 items'); + expect(getMobileNavTabAriaLabel(tab)).toBe('Patrol: 12 items'); }); it('returns the bare label for an alerts tab whose breakdown is all zeros (parts.length === 0 arm)', () => { @@ -225,7 +225,6 @@ describe('mobileNavBarModel.branchcov2', () => { [ makeUtilityTab({ id: 'settings' }), makeUtilityTab({ id: 'ai' }), - makeUtilityTab({ id: 'actions' }), makeUtilityTab({ id: 'alerts' }), ], ); @@ -233,7 +232,6 @@ describe('mobileNavBarModel.branchcov2', () => { expect(layout.fixedDestinations.map((destination) => destination.tab.id)).toEqual([ 'proxmox', 'alerts', - 'actions', 'ai', ]); expect(layout.overflowDestinations.map((destination) => destination.tab.id)).toEqual([ diff --git a/frontend-modern/src/components/shared/mobileNavBarModel.ts b/frontend-modern/src/components/shared/mobileNavBarModel.ts index abb14992d..163a4435b 100644 --- a/frontend-modern/src/components/shared/mobileNavBarModel.ts +++ b/frontend-modern/src/components/shared/mobileNavBarModel.ts @@ -16,7 +16,7 @@ export type MobileNavBarPrimaryTab = { }; export type MobileNavBarUtilityTab = { - id: 'alerts' | 'actions' | 'ai' | 'settings'; + id: 'alerts' | 'ai' | 'settings'; label: string; route: string; tooltip: string; @@ -53,12 +53,8 @@ const MOBILE_NAV_PRIMARY_PRIORITY = [ 'standalone', ] as const; -const MOBILE_NAV_UTILITY_PRIORITY = ['alerts', 'actions', 'ai', 'settings'] as const; -const MOBILE_NAV_FIXED_UTILITY_IDS = new Set([ - 'alerts', - 'actions', - 'ai', -]); +const MOBILE_NAV_UTILITY_PRIORITY = ['alerts', 'ai', 'settings'] as const; +const MOBILE_NAV_FIXED_UTILITY_IDS = new Set(['alerts', 'ai']); export function buildOrderedMobileNavTabs( tabs: T[], @@ -94,7 +90,7 @@ export function buildOrderedMobileNavUtilityTabs( /** * Keep the bottom bar to five predictable targets at most: the primary - * infrastructure destination, the three daily operations destinations, and + * infrastructure destination, the two daily operations destinations, and * More. Every other admitted platform and utility remains available in More. */ export function buildMobileNavBarLayout( diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx index a162629a4..d0910af08 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx @@ -1,6 +1,9 @@ import { createSignal, Show } from 'solid-js'; +import ListChecksIcon from 'lucide-solid/icons/list-checks'; +import { ButtonLink } from '@/components/shared/Button'; import { MetadataBadge } from '@/components/shared/MetadataBadge'; import { aiIntelligenceStore } from '@/stores/aiIntelligence'; +import { actionInboxStore } from '@/stores/actionInbox'; import { usePatrolIntelligenceState } from './usePatrolIntelligenceState'; import { PatrolIntelligenceHeader } from './PatrolIntelligenceHeader'; import { PatrolIntelligenceBanners } from './PatrolIntelligenceBanners'; @@ -32,6 +35,49 @@ export function PatrolIntelligenceSurface() { autonomyLocked={state.autoFixLocked()} onOpenFindings={openFindings} /> + +
+
+ + +
+
+

+ Activity history +

+ 0}> + + {actionInboxStore.pendingActionCount} to review + + +
+

+ Audit every governed operation, including work started elsewhere in Pulse. +

+
+
+ + Open activity history + +
+
{ expect(patrolIntelligenceHeaderSource).not.toContain('Sync page data'); expect(patrolIntelligenceHeaderSource).not.toContain('animate-spin'); expect(patrolIntelligenceHeaderSource).not.toContain('Refresh Patrol'); + expect(patrolIntelligenceSurfaceSource).toContain('Activity history'); + expect(patrolIntelligenceSurfaceSource).toContain('href="/actions"'); + expect(patrolIntelligenceSurfaceSource).toContain('Open activity history'); }); it('keeps the advanced Patrol settings drawer out of the old save-spinner path', () => { diff --git a/frontend-modern/src/routing/__tests__/navigation.test.ts b/frontend-modern/src/routing/__tests__/navigation.test.ts index 3588b59dd..50f8574b8 100644 --- a/frontend-modern/src/routing/__tests__/navigation.test.ts +++ b/frontend-modern/src/routing/__tests__/navigation.test.ts @@ -21,8 +21,6 @@ describe('navigation routing helpers', () => { expect(getActiveTabForPath('/ceph')).toBeNull(); expect(getActiveTabForPath('/recovery')).toBeNull(); expect(getActiveTabForPath('/alerts/open')).toBe('alerts'); - expect(getActiveTabForPath('/actions')).toBe('actions'); - expect(getActiveTabForPath('/actions/history')).toBe('actions'); expect(getActiveTabForPath('/patrol')).toBe('ai'); expect(getActiveTabForPath('/ai')).toBeNull(); expect(getActiveTabForPath('/operations')).toBeNull(); @@ -30,4 +28,11 @@ describe('navigation routing helpers', () => { expect(getActiveTabForPath('/operations/logs')).toBeNull(); expect(getActiveTabForPath('/settings/security')).toBe('settings'); }); + + it('keeps Activity history as a stable subordinate route identity', () => { + // AppLayout presents this route beneath Patrol, while route identity stays + // distinct for the page title, Assistant context, and durable action review. + expect(getActiveTabForPath('/actions')).toBe('actions'); + expect(getActiveTabForPath('/actions/history')).toBe('actions'); + }); }); diff --git a/frontend-modern/src/utils/assistantPageContext.ts b/frontend-modern/src/utils/assistantPageContext.ts index 525697cbe..36f6456e7 100644 --- a/frontend-modern/src/utils/assistantPageContext.ts +++ b/frontend-modern/src/utils/assistantPageContext.ts @@ -12,7 +12,7 @@ export interface AssistantPageContextPresentation { const VIEW_LABELS: Record, string> = { alerts: 'Alerts', - actions: 'Actions', + actions: 'Activity history', ai: 'Patrol', docker: 'Docker', kubernetes: 'Kubernetes',