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();