Fold the action ledger into Patrol navigation

This commit is contained in:
rcourtman
2026-08-14 13:08:12 +01:00
parent 48fac73ff0
commit daccf6c4d7
18 changed files with 210 additions and 85 deletions
@@ -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
@@ -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,
@@ -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
@@ -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
@@ -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,
@@ -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
+25 -25
View File
@@ -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"
]
}
+15 -17
View File
@@ -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,
},
@@ -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', () => {
@@ -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 = (
<Route path="/proxmox/overview" component={LayoutRoute} />
<Route path="/docker/overview" component={LayoutRoute} />
<Route path="/alerts" component={LayoutRoute} />
<Route path="/actions" component={LayoutRoute} />
</Router>
));
};
@@ -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<HTMLElement>(`[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' }),
@@ -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');
@@ -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([
@@ -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<MobileNavBarUtilityTab['id']>([
'alerts',
'actions',
'ai',
]);
const MOBILE_NAV_UTILITY_PRIORITY = ['alerts', 'ai', 'settings'] as const;
const MOBILE_NAV_FIXED_UTILITY_IDS = new Set<MobileNavBarUtilityTab['id']>(['alerts', 'ai']);
export function buildOrderedMobileNavTabs<T extends { id: string }>(
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(
@@ -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}
/>
<section
aria-labelledby="patrol-activity-history-title"
class="flex flex-col gap-4 rounded-lg border border-border bg-surface px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5"
>
<div class="flex min-w-0 items-start gap-3">
<span class="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-surface-alt text-muted">
<ListChecksIcon class="h-5 w-5" aria-hidden="true" />
</span>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h2
id="patrol-activity-history-title"
class="text-base font-semibold text-base-content"
>
Activity history
</h2>
<Show when={actionInboxStore.pendingActionCount > 0}>
<MetadataBadge
tone="warning"
size="sm"
shape="rounded"
aria-label={`${actionInboxStore.pendingActionCount} ${actionInboxStore.pendingActionCount === 1 ? 'operation needs' : 'operations need'} review`}
>
{actionInboxStore.pendingActionCount} to review
</MetadataBadge>
</Show>
</div>
<p class="mt-1 max-w-3xl text-sm leading-5 text-muted">
Audit every governed operation, including work started elsewhere in Pulse.
</p>
</div>
</div>
<ButtonLink
href="/actions"
variant="secondary"
size="sm"
class="w-full shrink-0 justify-center sm:w-auto"
>
Open activity history
</ButtonLink>
</section>
<PatrolRecentWorkPanel />
<details
+2 -2
View File
@@ -139,8 +139,8 @@ export function Actions() {
return (
<div class="space-y-6">
<PageHeader
title="Actions"
description="Review proposed infrastructure changes and track their outcomes."
title="Activity history"
description="Review every governed operation, approval, execution result, and verified outcome."
actions={
<Button
variant="ghost"
@@ -14,6 +14,7 @@ import {
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
} from '@/utils/pricingHandoff';
import patrolIntelligenceHeaderSource from '@/features/patrol/PatrolIntelligenceHeader.tsx?raw';
import patrolIntelligenceSurfaceSource from '@/features/patrol/PatrolIntelligenceSurface.tsx?raw';
import patrolIntelligenceStateSource from '@/features/patrol/usePatrolIntelligenceState.ts?raw';
import { AGENT_PATROL_CONTROL_STATUS_PATH } from '@/api/agentCapabilities';
@@ -547,6 +548,9 @@ describe('AIIntelligence entitlement gating', () => {
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', () => {
@@ -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');
});
});
@@ -12,7 +12,7 @@ export interface AssistantPageContextPresentation {
const VIEW_LABELS: Record<Exclude<ActiveAppTabId, null>, string> = {
alerts: 'Alerts',
actions: 'Actions',
actions: 'Activity history',
ai: 'Patrol',
docker: 'Docker',
kubernetes: 'Kubernetes',