From 7a102fc57fba621330d973db73c8bd7b8db1aeaf Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:46:05 +0100 Subject: [PATCH] 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. --- .../v6/internal/subsystems/api-contracts.md | 4 +- .../subsystems/frontend-primitives.md | 3 +- .../subsystems/patrol-intelligence.md | 9 +++ .../internal/subsystems/unified-resources.md | 5 ++ .../15547ceecd2bdefc.json | 47 +++++++++++ .../Actions.requestOwnership.test.tsx | 15 ++++ .../actions/__tests__/actionRouting.test.ts | 11 +++ .../src/features/actions/actionRouting.ts | 13 +++ .../patrol/PatrolIntelligenceSurface.tsx | 47 ++++++++--- .../PatrolIntelligenceSurface.test.tsx | 80 +++++++++++++++++-- .../features/patrol/patrolWorkspaceRouting.ts | 21 +++++ frontend-modern/src/pages/Actions.tsx | 21 +++-- .../pages/__tests__/AIIntelligence.test.tsx | 43 ++++++---- 13 files changed, 282 insertions(+), 37 deletions(-) create mode 100644 frontend-modern/browser-verification/15547ceecd2bdefc.json create mode 100644 frontend-modern/src/features/patrol/patrolWorkspaceRouting.ts diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index aa67fa3c82..7f010174c5 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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/` 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 diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index c1c7b52d3e..4da96a1ba0 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index e1775f65a0..dfbf50ed95 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index b43abc6032..0055746aea 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/browser-verification/15547ceecd2bdefc.json b/frontend-modern/browser-verification/15547ceecd2bdefc.json new file mode 100644 index 0000000000..e7c7450ccc --- /dev/null +++ b/frontend-modern/browser-verification/15547ceecd2bdefc.json @@ -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" + ] +} diff --git a/frontend-modern/src/features/actions/__tests__/Actions.requestOwnership.test.tsx b/frontend-modern/src/features/actions/__tests__/Actions.requestOwnership.test.tsx index bace802c09..6b9adf2b8b 100644 --- a/frontend-modern/src/features/actions/__tests__/Actions.requestOwnership.test.tsx +++ b/frontend-modern/src/features/actions/__tests__/Actions.requestOwnership.test.tsx @@ -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')); + }); }); diff --git a/frontend-modern/src/features/actions/__tests__/actionRouting.test.ts b/frontend-modern/src/features/actions/__tests__/actionRouting.test.ts index 84a3eccf10..7fef5f56d0 100644 --- a/frontend-modern/src/features/actions/__tests__/actionRouting.test.ts +++ b/frontend-modern/src/features/actions/__tests__/actionRouting.test.ts @@ -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'); + }); }); diff --git a/frontend-modern/src/features/actions/actionRouting.ts b/frontend-modern/src/features/actions/actionRouting.ts index 195ab5fc09..da6f848d86 100644 --- a/frontend-modern/src/features/actions/actionRouting.ts +++ b/frontend-modern/src/features/actions/actionRouting.ts @@ -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'; +} diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx index 07909ea780..234795d4fa 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx @@ -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('inbox'); + const location = useLocation(); + const navigate = useNavigate(); + const activeView = createMemo(() => parsePatrolWorkspaceView(location.pathname)); const [findingsOpen, setFindingsOpen] = createSignal(false); const [findingResourceScope, setFindingResourceScope] = createSignal(); const workspaceTabs: Partial> = {}; 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 ( diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSurface.test.tsx b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSurface.test.tsx index 52b80b17d5..9679ed439e 100644 --- a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSurface.test.tsx +++ b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSurface.test.tsx @@ -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 }) => {props.children}, })); +const renderSurface = () => + render(() => ( + + + + )); + 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(() => ); + 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(() => ); + 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(''); + }); }); diff --git a/frontend-modern/src/features/patrol/patrolWorkspaceRouting.ts b/frontend-modern/src/features/patrol/patrolWorkspaceRouting.ts new file mode 100644 index 0000000000..cd4fa2248c --- /dev/null +++ b/frontend-modern/src/features/patrol/patrolWorkspaceRouting.ts @@ -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'; +} diff --git a/frontend-modern/src/pages/Actions.tsx b/frontend-modern/src/pages/Actions.tsx index 63db3cfc9d..1294ba55df 100644 --- a/frontend-modern/src/pages/Actions.tsx +++ b/frontend-modern/src/pages/Actions.tsx @@ -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('pending'); + const navigate = useNavigate(); + const view = createMemo(() => 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(null); const [detailError, setDetailError] = createSignal(''); const [actions, setActions] = createSignal([]); @@ -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 }} /> diff --git a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx index 2b59fa2098..41c9269c4b 100644 --- a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx +++ b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx @@ -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; - }) => ( - - {props.children} - - ), - 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; + }) => ( + + {props.children} + + ), + 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();