Make Patrol and Actions views linkable

Patrol's Inbox, Protection and Activity views and the Actions Open and
History views lived in component state. A bookmark, a shared link or a
reload always opened the default view, and back/forward did nothing.

Each view is now a route: /patrol, /patrol/protection,
/patrol/activity, /actions and /actions/history. Tabs navigate without
scrolling the page. Lifecycle-driven switches in Actions (a deep-linked
review whose action has settled) replace history instead of pushing.
The Patrol finding handoff scrolls to the findings once Activity has
mounted. The Inbox's ?attention= selection is read from the real address
when leaving the Inbox and restored on return, because the attention
workbench writes it straight to history and the router's copy can be
stale.
This commit is contained in:
rcourtman
2026-10-05 03:46:05 +01:00
parent 5b7a9df8ac
commit 7a102fc57f
13 changed files with 282 additions and 37 deletions
@@ -426,7 +426,9 @@ machine. A correlated machine may carry the same check as an additive
availability facet, but that projection is not availability-source ownership
and must not suppress the endpoint from type filters, totals, REST reloads, or
websocket snapshots. Plural machine projections use `availabilityChecks`; the
singular `availability` field remains only a compatibility projection.
singular `availability` field remains only a compatibility projection. Its workspace view comes from the
`/patrol/<view>` route segment, not a query parameter or component state, so
no API payload carries or depends on the selected view.
The API-owned Relay adapter projects every canonical alert that reaches the
fired-dispatch callback into the governed Pulse Mobile push contract, subject
@@ -6429,7 +6429,8 @@ Feature-owned route surfaces under `frontend-modern/src/features/` must also
keep their shell/runtime split explicit once a subsystem grows real transport
or polling lifecycle. The Patrol feature is the current reference shape:
`frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx` stays the
feature shell, `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts`
feature shell, reads its active workspace view from the route through
`patrolWorkspaceRouting.ts` rather than holding it in component state, `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts`
owns the runtime state machine, `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`
owns the pure investigation-context summary and Patrol-to-Assistant operator
briefing derivation, including the rule that active findings, pending
@@ -490,6 +490,15 @@ attention`, `approval needed`, `outcome verified`, `no active work`) instead
unexplained `95/100` telemetry.
The Patrol workspace must default to current operator work: current findings,
active investigations, approval decisions, and selected finding snapshots.
The Inbox, Protection and Activity workspace views are routes (`/patrol`,
`/patrol/protection`, `/patrol/activity`) owned by
`frontend-modern/src/features/patrol/patrolWorkspaceRouting.ts`, so a link
or bookmark opens the view it was copied from, any other path opens the
Inbox, and the finding handoff from the Inbox navigates to Activity and
scrolls to the findings once Activity has mounted. View switches never
scroll the page. The Inbox `attention` query is read from the real address
when leaving the Inbox and restored on return, because the attention
workbench writes it straight to history and the router's copy can be stale.
The route and page title must lead with `Patrol`, because operators
understand Patrol as the recurring checking loop. The default workspace title
underneath it may use `Open work` so the page can explain current findings
@@ -1550,6 +1550,11 @@ typed action ids hand off to the route-backed Actions review through
parameter is the canonical shareable review identity: opening it fetches the
durable action directly, selects Open or History from the returned lifecycle
state, and clearing the dialog removes the query without discarding the inbox.
Open and History are routes (`/actions` and `/actions/history`) built and
parsed by the same module, so either view can be bookmarked or shared and
back/forward moves between them; a review switched to the other view by its
lifecycle state replaces rather than pushes history and keeps the `action`
query.
Patrol must not create a parallel decision or execution client around that
handoff.
The shared review may offer decision or execution controls only when the
@@ -0,0 +1,47 @@
{
"version": 1,
"base_sha": "5b7a9df8acda2b897ae593fd2f728d70bade5f84",
"verified_at": "2026-10-05T02:46:03Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/actions/actionRouting.ts",
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx",
"frontend-modern/src/features/patrol/patrolWorkspaceRouting.ts",
"frontend-modern/src/pages/Actions.tsx"
],
"content_sha256": {
"frontend-modern/src/features/actions/actionRouting.ts": "03e843f15212f590eda7660fced7964c15a95fb0365e322b559d4ed025a4ebcc",
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "117baebb4a64ea1c38a74bfc9295f69ba5d5b70c1f3d66aba90cb089edabbfb1",
"frontend-modern/src/features/patrol/patrolWorkspaceRouting.ts": "74a545cf0db482bf4ca55b0d1ff338c1305e8b7646e216b329a6b45688783620",
"frontend-modern/src/pages/Actions.tsx": "1c2da103bf18c622e6e9478ba0c0b14a61597213eb3e308cee1463de53ae8a04"
},
"routes": [
"/actions",
"/actions/history",
"/patrol",
"/patrol/protection",
"/patrol/activity"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Deep link /actions/history selects History and lists recorded outcomes",
"Deep link /patrol/activity selects Activity; /patrol selects Inbox",
"Inbox selection keeps ?attention= on the Inbox address",
"No console errors"
],
"interactions": [
"Actions: History deep link, click Open (path /actions), browser back returns to /actions/history with History selected",
"Patrol: Activity deep link, click Protection (path /patrol/protection), browser back returns to /patrol/activity",
"Patrol: select an inbox item, click Activity then Inbox: the ?attention selection and its Browse findings control return",
"Patrol: Browse Patrol findings for this resource lands on /patrol/activity scrolled to the findings with focus on the findings panel"
]
}
@@ -171,4 +171,19 @@ describe('Actions request ownership', () => {
screen.queryByRole('button', { name: /docker:container:edge-a/ }),
).not.toBeInTheDocument();
});
it('opens History from its route and returns to Open through the tab', async () => {
window.history.replaceState(null, '', '/actions/history');
renderActions();
expect(await screen.findByRole('tab', { name: 'History' })).toHaveAttribute(
'aria-selected',
'true',
);
await waitFor(() => expect(ResourceActionsAPI.listActions).toHaveBeenLastCalledWith('settled'));
fireEvent.click(screen.getByRole('tab', { name: 'Open' }));
await waitFor(() => expect(window.location.pathname).toBe('/actions'));
await waitFor(() => expect(ResourceActionsAPI.listActions).toHaveBeenLastCalledWith('pending'));
});
});
@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest';
import {
ACTION_REVIEW_QUERY_PARAM,
buildActionReviewPath,
buildActionsViewPath,
parseActionReviewId,
parseActionsView,
} from '../actionRouting';
describe('action review routing', () => {
@@ -21,4 +23,13 @@ describe('action review routing', () => {
expect(parseActionReviewId('?action=%20action-1%20')).toBe('action-1');
expect(parseActionReviewId('?view=settled')).toBe('');
});
it('routes the Open and History views so either can be linked', () => {
expect(buildActionsViewPath('pending')).toBe('/actions');
expect(buildActionsViewPath('settled')).toBe('/actions/history');
expect(parseActionsView('/actions/history')).toBe('settled');
expect(parseActionsView('/actions/history/')).toBe('settled');
expect(parseActionsView('/actions')).toBe('pending');
expect(parseActionsView('/actions/historyx')).toBe('pending');
});
});
@@ -1,4 +1,5 @@
import { ACTIONS_PATH } from '@/routing/resourceLinks';
import type { ActionInboxView } from '@/types/actionAudit';
export const ACTION_REVIEW_QUERY_PARAM = 'action';
@@ -15,3 +16,15 @@ export function buildActionReviewPath(actionId?: string | null): string {
export function parseActionReviewId(search: string): string {
return normalizeActionId(new URLSearchParams(search).get(ACTION_REVIEW_QUERY_PARAM));
}
// The Open and History views are routes, so a bookmark or shared link opens
// the view it was copied from and back/forward moves between them.
export const ACTIONS_HISTORY_PATH = `${ACTIONS_PATH}/history`;
export function buildActionsViewPath(view: ActionInboxView): string {
return view === 'settled' ? ACTIONS_HISTORY_PATH : ACTIONS_PATH;
}
export function parseActionsView(pathname: string): ActionInboxView {
return /^\/actions\/history\/?$/.test(pathname) ? 'settled' : 'pending';
}
@@ -1,4 +1,11 @@
import { createSignal, Show } from 'solid-js';
import { useLocation, useNavigate } from '@solidjs/router';
import { createEffect, createMemo, createSignal, Show } from 'solid-js';
import {
buildPatrolWorkspacePath,
parsePatrolWorkspaceView,
PATROL_WORKSPACE_VIEWS,
type PatrolWorkspaceView,
} from './patrolWorkspaceRouting';
import ArrowRightIcon from 'lucide-solid/icons/arrow-right';
import ClipboardCheckIcon from 'lucide-solid/icons/clipboard-check';
import HistoryIcon from 'lucide-solid/icons/history';
@@ -16,9 +23,6 @@ import { PatrolRecentWorkPanel } from './PatrolRecentWorkPanel';
import { PatrolWeeklyDigestCard } from './PatrolWeeklyDigestCard';
import type { AttentionItem } from '@/api/patrolAttention';
type PatrolWorkspaceView = 'inbox' | 'protection' | 'activity';
const PATROL_WORKSPACE_VIEWS: readonly PatrolWorkspaceView[] = ['inbox', 'protection', 'activity'];
interface FindingResourceScope {
id: string;
name: string;
@@ -26,15 +30,39 @@ interface FindingResourceScope {
export function PatrolIntelligenceSurface() {
const state = usePatrolIntelligenceState();
const [activeView, setActiveView] = createSignal<PatrolWorkspaceView>('inbox');
const location = useLocation();
const navigate = useNavigate();
const activeView = createMemo(() => parsePatrolWorkspaceView(location.pathname));
const [findingsOpen, setFindingsOpen] = createSignal(false);
const [findingResourceScope, setFindingResourceScope] = createSignal<FindingResourceScope>();
const workspaceTabs: Partial<Record<PatrolWorkspaceView, HTMLButtonElement>> = {};
let findingsPanel: HTMLDetailsElement | undefined;
// Views switch in place, so navigation must not scroll to the top.
// `?attention=` is Inbox-only state that the attention workbench writes
// straight to history, so the router's copy of the query can be stale. The
// Inbox query is taken from the real address when leaving the Inbox and
// restored on return: a selection survives a look at Activity, and a closed
// one cannot come back.
let inboxSearch = '';
const activateView = (view: PatrolWorkspaceView, focus = false) => {
setActiveView(view);
if (activeView() !== view) {
if (activeView() === 'inbox') inboxSearch = window.location.search;
const search = view === 'inbox' ? inboxSearch : '';
navigate(`${buildPatrolWorkspacePath(view)}${search}`, { scroll: false });
}
if (focus) queueMicrotask(() => workspaceTabs[view]?.focus());
};
// The finding handoff scrolls to the findings panel only once Activity has
// mounted, so neither the route change nor the mount can undo it.
const [pendingFindingsFocus, setPendingFindingsFocus] = createSignal(false);
createEffect(() => {
if (activeView() !== 'activity' || !pendingFindingsFocus()) return;
setPendingFindingsFocus(false);
queueMicrotask(() => {
findingsPanel?.scrollIntoView?.({ block: 'start' });
findingsPanel?.focus?.({ preventScroll: true });
});
});
const handleWorkspaceKeyDown = (event: KeyboardEvent, currentView: PatrolWorkspaceView) => {
const currentIndex = PATROL_WORKSPACE_VIEWS.indexOf(currentView);
const requestedIndex =
@@ -59,12 +87,9 @@ export function PatrolIntelligenceSurface() {
id: item.subjectResourceId,
name: item.subjectResourceName || item.subjectResourceId,
});
activateView('activity');
setFindingsOpen(true);
queueMicrotask(() => {
findingsPanel?.scrollIntoView?.({ block: 'start' });
findingsPanel?.focus?.({ preventScroll: true });
});
setPendingFindingsFocus(true);
activateView('activity');
};
return (
@@ -1,6 +1,7 @@
import { Route, Router } from '@solidjs/router';
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import type { JSX } from 'solid-js';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PatrolIntelligenceSurface } from '../PatrolIntelligenceSurface';
@@ -74,7 +75,18 @@ vi.mock('@/components/shared/MetadataBadge', () => ({
MetadataBadge: (props: { children?: JSX.Element }) => <span>{props.children}</span>,
}));
const renderSurface = () =>
render(() => (
<Router>
<Route path="*" component={PatrolIntelligenceSurface} />
</Router>
));
describe('PatrolIntelligenceSurface finding handoff', () => {
beforeEach(() => {
window.history.replaceState(null, '', '/patrol');
});
afterEach(() => {
cleanup();
patrolState.setupOnly = false;
@@ -85,7 +97,7 @@ describe('PatrolIntelligenceSurface finding handoff', () => {
patrolState.setupOnly = true;
patrolState.enabled = false;
render(() => <PatrolIntelligenceSurface />);
renderSurface();
expect(screen.getByRole('tab', { name: 'Inbox' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('button', { name: 'Open scoped finding options' })).toBeInTheDocument();
@@ -93,12 +105,18 @@ describe('PatrolIntelligenceSurface finding handoff', () => {
expect(screen.getByTestId('patrol-workspace').parentElement).toHaveClass('pointer-events-none');
});
it('keeps the selected decision resource in context and lets the operator broaden the list', () => {
render(() => <PatrolIntelligenceSurface />);
it('keeps the selected decision resource in context and lets the operator broaden the list', async () => {
renderSurface();
fireEvent.click(screen.getByRole('button', { name: 'Open scoped finding options' }));
expect(screen.getByRole('tab', { name: 'Activity' })).toHaveAttribute('aria-selected', 'true');
await vi.waitFor(() =>
expect(screen.getByRole('tab', { name: 'Activity' })).toHaveAttribute(
'aria-selected',
'true',
),
);
expect(window.location.pathname).toBe('/patrol/activity');
expect(screen.getByText(/Showing Patrol findings for Database VM/i)).toBeInTheDocument();
expect(screen.getByTestId('patrol-workspace')).toHaveAttribute('data-resource-id', 'vm-101');
@@ -107,4 +125,56 @@ describe('PatrolIntelligenceSurface finding handoff', () => {
expect(screen.getByTestId('patrol-workspace')).toHaveAttribute('data-resource-id', '');
expect(screen.queryByText(/Showing Patrol findings for Database VM/i)).not.toBeInTheDocument();
});
it('opens the workspace view named by the route and routes each tab change', async () => {
window.history.replaceState(null, '', '/patrol/activity');
renderSurface();
expect(screen.getByRole('tab', { name: 'Activity' })).toHaveAttribute('aria-selected', 'true');
fireEvent.click(screen.getByRole('tab', { name: 'Protection' }));
await vi.waitFor(() => expect(window.location.pathname).toBe('/patrol/protection'));
expect(screen.getByRole('tab', { name: 'Protection' })).toHaveAttribute(
'aria-selected',
'true',
);
fireEvent.click(screen.getByRole('tab', { name: 'Inbox' }));
await vi.waitFor(() => expect(window.location.pathname).toBe('/patrol'));
});
it('lands the finding handoff on the findings panel without a scroll to the top', async () => {
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
const scrollIntoView = vi.fn();
HTMLElement.prototype.scrollIntoView = scrollIntoView;
renderSurface();
fireEvent.click(screen.getByRole('button', { name: 'Open scoped finding options' }));
await vi.waitFor(() => expect(scrollIntoView).toHaveBeenCalled());
expect(scrollTo).not.toHaveBeenCalled();
scrollTo.mockRestore();
});
it('restores the live Inbox selection on return and never a closed one', async () => {
window.history.replaceState(null, '', '/patrol?attention=chosen');
renderSurface();
fireEvent.click(screen.getByRole('tab', { name: 'Activity' }));
await vi.waitFor(() => expect(window.location.pathname).toBe('/patrol/activity'));
expect(window.location.search).toBe('');
fireEvent.click(screen.getByRole('tab', { name: 'Inbox' }));
await vi.waitFor(() => expect(window.location.pathname).toBe('/patrol'));
expect(window.location.search).toBe('?attention=chosen');
// The workbench closes a selection by writing history directly, which the
// router does not see. The next round trip must follow the real address.
window.history.replaceState(null, '', '/patrol');
fireEvent.click(screen.getByRole('tab', { name: 'Activity' }));
await vi.waitFor(() => expect(window.location.pathname).toBe('/patrol/activity'));
fireEvent.click(screen.getByRole('tab', { name: 'Inbox' }));
await vi.waitFor(() => expect(window.location.pathname).toBe('/patrol'));
expect(window.location.search).toBe('');
});
});
@@ -0,0 +1,21 @@
import { PATROL_PATH } from '@/routing/resourceLinks';
export type PatrolWorkspaceView = 'inbox' | 'protection' | 'activity';
export const PATROL_WORKSPACE_VIEWS: readonly PatrolWorkspaceView[] = [
'inbox',
'protection',
'activity',
];
// Each workspace view is a route (/patrol, /patrol/protection,
// /patrol/activity), so a bookmark or shared link opens the view it was copied
// from and back/forward moves between them.
export function buildPatrolWorkspacePath(view: PatrolWorkspaceView): string {
return view === 'inbox' ? PATROL_PATH : `${PATROL_PATH}/${view}`;
}
export function parsePatrolWorkspaceView(pathname: string): PatrolWorkspaceView {
const match = /^\/patrol\/(protection|activity)\/?$/.exec(pathname);
return match ? (match[1] as PatrolWorkspaceView) : 'inbox';
}
+16 -5
View File
@@ -1,5 +1,5 @@
import { Show, createEffect, createMemo, createSignal, onMount } from 'solid-js';
import { useLocation, useSearchParams } from '@solidjs/router';
import { useLocation, useNavigate, useSearchParams } from '@solidjs/router';
import ChevronRightIcon from 'lucide-solid/icons/chevron-right';
import EyeIcon from 'lucide-solid/icons/eye';
import RefreshCwIcon from 'lucide-solid/icons/refresh-cw';
@@ -22,7 +22,12 @@ import { PageHeader } from '@/components/shared/PageHeader';
import { Subtabs } from '@/components/shared/Subtabs';
import { ActionReviewDialog } from '@/features/actions/ActionReviewDialog';
import { PlatformWindowedList } from '@/features/platformPage/PlatformWindowedList';
import { ACTION_REVIEW_QUERY_PARAM, parseActionReviewId } from '@/features/actions/actionRouting';
import {
ACTION_REVIEW_QUERY_PARAM,
buildActionsViewPath,
parseActionReviewId,
parseActionsView,
} from '@/features/actions/actionRouting';
import {
formatActionName,
getActionOriginLabel,
@@ -47,7 +52,13 @@ const getInboxViewForState = (state: ActionAuditState): ActionInboxView =>
export function Actions() {
const location = useLocation();
const [, setSearchParams] = useSearchParams();
const [view, setView] = createSignal<ActionInboxView>('pending');
const navigate = useNavigate();
const view = createMemo<ActionInboxView>(() => parseActionsView(location.pathname));
const setView = (next: ActionInboxView, options: { replace?: boolean } = {}) => {
if (view() === next) return;
// The views switch in place, so navigation must not scroll to the top.
navigate(`${buildActionsViewPath(next)}${location.search}`, { ...options, scroll: false });
};
const [selected, setSelected] = createSignal<ActionDetailResponse | null>(null);
const [detailError, setDetailError] = createSignal('');
const [actions, setActions] = createSignal<ActionAuditRecord[]>([]);
@@ -127,7 +138,7 @@ export function Actions() {
return;
}
setSelected(detail);
setView(getInboxViewForState(detail.audit.state));
setView(getInboxViewForState(detail.audit.state), { replace: true });
} catch (cause) {
if (
generation !== detailRequestGeneration ||
@@ -362,7 +373,7 @@ export function Actions() {
setSelected(detail);
const nextView = getInboxViewForState(detail.audit.state);
if (view() === nextView) await loadActions();
else setView(nextView); // the view effect loads the new list once
else setView(nextView, { replace: true }); // the view effect loads the new list once
}}
/>
</div>
@@ -126,20 +126,34 @@ const getUpgradeActionUrlOrFallbackMock = vi.fn();
const presentationPolicyHidesUpgradePromptsMock = vi.fn();
const presentationPolicyHidesCommercialSurfacesMock = vi.fn();
vi.mock('@solidjs/router', () => ({
A: (props: {
href: string;
class?: string;
children?: JSX.Element;
'aria-label'?: string;
title?: string;
}) => (
<a href={props.href} class={props.class} aria-label={props['aria-label']} title={props.title}>
{props.children}
</a>
),
useLocation: () => ({ hash: '', pathname: '/patrol', search: '' }),
}));
// Workspace views are routes, so the mocked location follows navigate().
const routerLocation = vi.hoisted(() => ({ setPath: (_path: string) => {} }));
vi.mock('@solidjs/router', async () => {
const { createSignal } = await import('solid-js');
const [pathname, setPathname] = createSignal('/patrol');
routerLocation.setPath = setPathname;
return {
A: (props: {
href: string;
class?: string;
children?: JSX.Element;
'aria-label'?: string;
title?: string;
}) => (
<a href={props.href} class={props.class} aria-label={props['aria-label']} title={props.title}>
{props.children}
</a>
),
useLocation: () => ({
hash: '',
get pathname() {
return pathname();
},
search: '',
}),
useNavigate: () => (to: string) => setPathname(to.split('?')[0]),
};
});
const notificationSuccessMock = vi.fn();
const notificationErrorMock = vi.fn();
const recordWorkflowPromptActivityMock = vi.fn();
@@ -621,6 +635,7 @@ describe('AIIntelligence entitlement gating', () => {
});
beforeEach(() => {
routerLocation.setPath('/patrol');
resetCreateNonSuspendingQueryCacheForTest();
resetAIRuntimeState();
getPatrolStatusMock.mockReset();