Integrate AI-off navigation and Actions copy with main repairs

Compose protected Pulse PRs #2312 and #2313 with the reviewed main candidate so first-source setup guidance, the corrected E2E assertion, and later repairs can be verified together. Browser proof for the composed source follows in a separate receipt commit.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-28 23:04:42 +01:00
33 changed files with 282 additions and 102 deletions
@@ -8469,3 +8469,18 @@ complete new snapshot, never a partially written file. This is an internal
durability boundary, not a public API or stored-format change. The
`TestKnowledgeStore_SaveLoad` cleanup join and the concurrent-save reload
regression in `internal/ai/adapters/adapters_additional_test.go` enforce it.
### AI entry points follow the assistant session capability
Every AI entry point in the app shell follows the `assistantEnabled` session
capability (AI enabled and a provider configured, or mock mode). That covers
the Assistant launcher and Assistant command-palette commands, and the Patrol
top-level tab, mobile bottom-bar slot, `g r` shortcut, shortcuts-help row and
`Go to Patrol` palette command. Patrol cannot run without a configured
provider, so an AI-off install no longer carries navigation that only reaches
an "off" setup page, which restores the issue #905 behaviour for Patrol. The
Settings Patrol and Assistant items and the `/patrol` route stay reachable, so
turning AI back on needs no new path. This is presentation gating only: Patrol
runtime, findings, schedules and the capability's server derivation are
unchanged. `AppLayout.test.tsx`, `CommandPaletteModal.test.tsx`,
`KeyboardShortcutsModal.test.tsx` and `App.architecture.test.ts` pin it.
@@ -3630,3 +3630,13 @@ activation semantics. The offline mobile audit explicitly activates the default
context before it creates and activates isolated organizations, using the normal
authenticated API. Its route/overflow checks and AppLayout regression cover this
boundary without granting Community private RBAC or changing CI tier membership.
### AI navigation gating stays outside commercial scope
The app shell hides Patrol navigation and Assistant palette commands while the
`assistantEnabled` session capability is false. The gate reads only that AI
capability: it does not consult entitlements, organization scope, hosted
bootstrap or upgrade state, and it adds no commercial prompt. Paid Patrol
modes stay governed on the Patrol and Actions surfaces, which remain
reachable by route. `App.architecture.test.ts` pins that the gate reads the
same capability as the Assistant launcher.
@@ -6874,7 +6874,8 @@ and
### Agent Doctor settings framing
Settings labels the application update panel **Pulse server updates** and keeps
Settings labels the application update panel **Updates** under the System
group, whose description sends agent updates to Infrastructure, and keeps
agent lifecycle triage in the separate **Agent Doctor** dialog. Platform update
notices, Diagnostics, and Infrastructure rows use the canonical Agent Doctor
route handoff instead of recreating installer or repair controls. The dialog
@@ -7460,3 +7461,13 @@ also follows the outdated-agent action to Agent Doctor with all 49 host IDs.
The broader palette override should be corrected in its own shared-design
slice, with background users migrated to explicit alpha utilities so other
900-level text consumers can use normal opaque color semantics.
### Actions empty state follows the AI capability
`getActionsWatchOnlyEmptyState` takes an explicit `aiEnabled` input, which
`pages/Actions.tsx` fills from the `assistantEnabled` session capability. While
AI is off it returns no guidance, so the empty Open inbox keeps its plain copy
instead of saying Patrol runs in Watch only mode or pitching Pro Patrol modes
for a feature that is not running (issue #905). With AI on, the Watch only,
switch and upgrade branches are unchanged. `actionsWatchOnlyEmptyState.test.ts`
pins both states and the page wiring.
@@ -2906,3 +2906,11 @@ trust suite verifies every consumer pin, absence of the affected auth assumption
workflow trust controls and the retained native Windows command/lifecycle proof
steps. Native Windows execution remains a hosted check, not a local Linux claim.
This upgrade is independent of the grouped signing/Docker/Tailscale updates.
### Updates settings copy rename
The System settings item formerly labelled "Pulse server updates" is now
"Updates" in English, German and Spanish, with a plain-language description
that still routes agent updates to Infrastructure. This is locale copy only:
the `system-updates` route id, its `systemSettingsRead` capability gate and
every update endpoint and authorization check are unchanged.
@@ -6166,3 +6166,11 @@ walk the same group repeatedly during large-estate Storage navigation. The
windowing test pins the row shape, and the 50-node browser fixture exercises
expanded rows and History at desktop and phone widths. This is presentation
work only; it changes no storage evidence, alert state or recovery authority.
### App-shell AI navigation gating opens no recovery path
`App.tsx` now passes the `assistantEnabled` session capability to the keyboard
shortcuts and the shortcuts help so Patrol entry points hide while AI is off.
The change is navigation presentation only. It adds no storage, backup,
snapshot or recovery surface, and it changes no route, authority or recovery
state. The Recovery settings item and its capability gate are untouched.
@@ -5396,3 +5396,11 @@ versus 4.953ms (+0.7%), with unchanged allocations. The 200-host merge was
4.7µs and 19 allocations. These paired runs remove the observed local ingest
regression. The revised #2260 CI Benchmarks job passed on commit
`bbce69ea65` with no benchmark regression gate failure.
### Actions empty-state copy does not touch the action ledger
The Actions page now hides its Patrol Watch only guidance while the
`assistantEnabled` session capability is false. This is empty-state
presentation only: the action audit list, approval counts, review dialog,
routing and every recorded action outcome are read and rendered exactly as
before, and no resource or action identity changes.
+16 -23
View File
@@ -1,42 +1,35 @@
{
"version": 1,
"base_sha": "ab4f04da3d67d20c51943666be9bf4515ebb3b3f",
"verified_at": "2026-09-28T17:08:52Z",
"base_sha": "a866b27f2a7f038ae06d2c2a404ebfe16375b687",
"verified_at": "2026-09-28T21:20:00Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/i18n/messages.de.ts",
"frontend-modern/src/i18n/messages.es.ts",
"frontend-modern/src/i18n/messages.ts"
"frontend-modern/src/features/actions/actionPresentation.ts",
"frontend-modern/src/pages/Actions.tsx"
],
"content_sha256": {
"frontend-modern/src/i18n/messages.de.ts": "4300b8dbbc163a1010dfd0c93d4d326b7ef05da02aedabbcfff393ba39b5f220",
"frontend-modern/src/i18n/messages.es.ts": "cd15c47444aaf7d823cd82b3b63cdad288a47da7bbb3393089eb8d3130f826a9",
"frontend-modern/src/i18n/messages.ts": "3e5f3057c10da70b92bc1c4343cede927c01979af7b1f8d00958d0c1d1ab60a0"
"frontend-modern/src/features/actions/actionPresentation.ts": "8e67a99e744cf7dbbc1fc53da1d9697af78a90d95779df3553ee8867c3c51496",
"frontend-modern/src/pages/Actions.tsx": "88ab28e9333652e81770e570c140aa19f0b6d833be3b65fd41afd2e9614f60c5"
},
"routes": [
"/preview/setup-complete?scenario=empty",
"/preview/setup-complete?scenario=vmware-api-backed"
"/actions"
],
"viewports": [
{
"width": 1280,
"height": 800
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
"width": 375,
"height": 812
}
],
"states": [
"Empty and API-connected setup-completion previews rendered in English, German and Spanish at both widths",
"Host installer and conditional temperature/SMART coverage copy displayed without horizontal overflow or uncaught page errors",
"Credential section collapsed after activating its disclosure in each of the twelve states"
"AI off (assistantEnabled false, non-mock backend) before the fix: empty Open tab read Patrol runs in Watch only mode with a Pulse Pro pitch",
"AI off after the fix: empty Open tab reads No actions need attention and Pulse will place proposed or in-progress changes here for review, at 1440px and 375px",
"AI on (mock backend): Open tab lists demo actions from Patrol, empty-state guidance not rendered"
],
"interactions": [
"Opened empty and API-connected previews for each locale at desktop and narrow widths",
"Collapsed the credentials disclosure and verified the preview password was hidden"
],
"command": "pulse-worker-browser web-setup-proof.cjs from the exact assigned workspace root; local Vite preview and Chromium 141.0.7390.37",
"artifacts": "/var/lib/pulse-maintainer/worker-outputs/web-product-zonkacnc/browser-proof-artifacts",
"notes": "No backend or installed release was exercised. Screenshots were inspected for English desktop, English API-connected narrow, German empty narrow and Spanish API-connected narrow; result.json records all twelve rendered states. The proof script is retained beside the artifacts."
"Reloaded /actions at desktop and phone widths in both AI states"
]
}
+2
View File
@@ -460,6 +460,7 @@ function App() {
useKeyboardShortcuts({
enabled: () => !runtime.needsAuth(),
platformVisibility: platformNavigationVisibility,
patrolVisible: () => aiChatStore.enabled === true,
isShortcutsOpen: shortcutsOpen,
isCommandPaletteOpen: commandPaletteOpen,
onToggleShortcuts: () => {
@@ -592,6 +593,7 @@ function App() {
isOpen={shortcutsOpen()}
onClose={() => setShortcutsOpen(false)}
platformVisibility={platformNavigationVisibility}
patrolVisible={() => aiChatStore.enabled === true}
/>
</Show>
<Show when={commandPaletteOpen()}>
+19 -11
View File
@@ -603,17 +603,25 @@ export function AppLayout(props: AppLayoutProps) {
breakdown,
icon: BellIcon,
},
{
id: 'ai',
label: 'Patrol',
route: '/patrol',
tooltip: 'Review active operational attention and recent Patrol checks',
badge: null,
count: patrolAttentionCount() || undefined,
countLabel: patrolAttentionCountLabel(),
breakdown: undefined,
icon: PulsePatrolLogo,
},
// Patrol cannot run without an enabled, configured AI provider, so its
// tab follows the same session capability as the Assistant launcher.
// Turning AI off in Settings hides every AI entry point (#905); the
// route itself stays reachable for links and the settings handoff.
...(aiChatStore.enabled === true
? [
{
id: 'ai' as const,
label: 'Patrol',
route: '/patrol',
tooltip: 'Review active operational attention and recent Patrol checks',
badge: null,
count: patrolAttentionCount() || undefined,
countLabel: patrolAttentionCountLabel(),
breakdown: undefined,
icon: PulsePatrolLogo,
},
]
: []),
{
id: 'actions',
label: 'Actions',
@@ -234,6 +234,17 @@ describe('Global update progress authorization', () => {
});
describe('App architecture', () => {
it('gates Patrol navigation on the same AI capability as the Assistant launcher (#905)', () => {
expect(appSource).toContain('patrolVisible: () => aiChatStore.enabled === true');
expect(appSource).toContain('patrolVisible={() => aiChatStore.enabled === true}');
expect(appLayoutSource).toMatch(
/\.\.\.\(aiChatStore\.enabled === true\s*\?\s*\[\s*\{\s*id: 'ai' as const/,
);
expect(appRuntimeStateSource).toContain(
'aiChatStore.setEnabled(securityData?.sessionCapabilities?.assistantEnabled === true)',
);
});
it('keeps first-load connection progress understandable and retryable', () => {
expect(appSource).toContain('fallback={<AppBootstrapStatus />}');
expect(appSource).not.toContain('<div class="text-muted">Loading...</div>');
@@ -291,6 +291,22 @@ describe('AppLayout navigation icons', () => {
expect(within(mobileNav).queryByText('Needs Attention')).toBeNull();
});
it('hides the Patrol tab on desktop and mobile while AI is off (#905)', () => {
aiChatStore.setEnabled(false);
renderLayout();
const desktopNav = screen.getByRole('navigation', { name: 'Primary navigation' });
const systemGroup = desktopNav.querySelector('[aria-label="System"]');
expect(systemGroup).toBeTruthy();
expect(within(systemGroup as HTMLElement).queryByRole('link', { name: 'Patrol' })).toBeNull();
expect(within(systemGroup as HTMLElement).getByRole('link', { name: 'Actions' })).toBeTruthy();
const mobileNav = screen.getByRole('navigation', { name: 'Mobile navigation' });
expect(mobileNav.querySelector('[data-tab-id="ai"]')).toBeNull();
expect(mobileNav.querySelector('[data-tab-id="alerts"]')).toBeTruthy();
expect(mobileNav.querySelector('[data-tab-id="actions"]')).toBeTruthy();
});
it('gives Actions its own navigation state and approval count', () => {
actionInboxMockState.pendingActionCount = 3;
renderLayout([], '/actions');
@@ -429,7 +429,7 @@ describe('settings architecture guardrails', () => {
it('gates the admin-only System tabs on the served systemSettingsRead capability', () => {
// Same rationale as Infrastructure rather than the paid-feature one: a free
// install can act on a paid tab by upgrading, but a non-admin cannot grant
// themselves admin, so Network / Pulse server updates / Recovery can only
// themselves admin, so Network / Updates / Recovery can only
// end in a panel they will never populate.
for (const id of ['system-network', 'system-updates', 'system-recovery']) {
const navBlock = settingsNavCatalogSource.match(
@@ -487,12 +487,12 @@ describe('settings architecture guardrails', () => {
);
});
it('keeps Pulse server updates separate from Agent Doctor lifecycle triage', () => {
it('keeps Pulse updates separate from Agent Doctor lifecycle triage', () => {
const updatesNavBlock = settingsNavCatalogSource.match(
/id: 'system-updates',[\s\S]*?id: 'system-recovery',/,
);
expect(updatesNavBlock?.[0]).toContain("label: 'Pulse server updates'");
expect(settingsHeaderMetaSource).toContain("title: 'Pulse server updates'");
expect(updatesNavBlock?.[0]).toContain("label: 'Updates'");
expect(settingsHeaderMetaSource).toContain("title: 'Updates'");
expect(settingsNavCatalogSource).not.toContain("label: 'Agent Doctor'");
});
@@ -118,9 +118,9 @@ describe('getSettingsHeaderMeta', () => {
it('localizes system-updates to the English baseline', () => {
expect(en['system-updates']).toEqual({
title: 'Pulse server updates',
title: 'Updates',
description:
'Manage Pulse server runtime version checks, update channels, and automatic updates. Agent updates stay under Infrastructure.',
'Check for new Pulse versions, choose an update channel, and manage automatic updates. Agent updates stay under Infrastructure.',
});
});
@@ -416,9 +416,9 @@ describe('getSettingsHeaderMeta', () => {
it('localizes system-updates', () => {
expect(de['system-updates']).toEqual({
title: 'Pulse-Server-Updates',
title: 'Updates',
description:
'Verwalten Sie Versionspruefungen, Update-Kanaele und automatische Updates der Pulse-Server-Laufzeit. Agent-Updates bleiben unter Infrastruktur.',
'Suchen Sie nach neuen Pulse-Versionen, waehlen Sie einen Update-Kanal und verwalten Sie automatische Updates. Agent-Updates bleiben unter Infrastruktur.',
});
});
@@ -612,9 +612,9 @@ describe('getSettingsHeaderMeta', () => {
it('localizes system-updates', () => {
expect(es['system-updates']).toEqual({
title: 'Actualizaciones del servidor Pulse',
title: 'Actualizaciones',
description:
'Administra las comprobaciones de versión, los canales y las actualizaciones automáticas del servidor Pulse. Las actualizaciones de agentes permanecen en Infraestructura.',
'Busca nuevas versiones de Pulse, elige un canal de actualización y administra las actualizaciones automáticas. Las actualizaciones de agentes permanecen en Infraestructura.',
});
});
@@ -34,7 +34,7 @@ describe('settings localization catalog', () => {
const groups = getSettingsNavGroups('de');
expect(groups[0]?.label).toBe('Infrastruktur');
expect(groups[0]?.items[0]?.label).toBe('Infrastruktur');
expect(getSettingsNavItem('system-updates', 'de')?.label).toBe('Pulse-Server-Updates');
expect(getSettingsNavItem('system-updates', 'de')?.label).toBe('Updates');
expect(getSettingsNavItem('system-ai-assistant', 'de')?.label).toBe('Assistant');
expect(getSettingsNavItem('system-ai-discovery', 'de')).toBeUndefined();
expect(getSettingsNavItem('security-data-handling', 'de')?.label).toBe('Ressourcenschutz');
@@ -6,7 +6,7 @@ import {
type SettingsNavVisibilityContext,
} from '../settingsNavVisibility';
// System → Network, Pulse server updates, and Recovery are pure instance
// System → Network, Updates, and Recovery are pure instance
// administration: the public URL and CORS boundaries, the server update
// channel, and backup polling plus config export/import. Every route behind
// them is RequireAdmin + settings:read, so a session without it was offered
@@ -25,9 +25,9 @@ export const SETTINGS_HEADER_META: SettingsHeaderMetaMap = {
description: 'Configure the public URL, CORS, embedding, and webhook network boundaries.',
},
'system-updates': {
title: 'Pulse server updates',
title: 'Updates',
description:
'Manage Pulse server runtime version checks, update channels, and automatic updates. Agent updates stay under Infrastructure.',
'Check for new Pulse versions, choose an update channel, and manage automatic updates. Agent updates stay under Infrastructure.',
},
'system-recovery': {
title: 'Recovery',
@@ -190,7 +190,7 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
},
{
id: 'system-updates',
label: 'Pulse server updates',
label: 'Updates',
icon: RefreshCw,
iconProps: { strokeWidth: 2 },
saveBehavior: 'system',
@@ -14,6 +14,7 @@ interface KeyboardShortcutsModalProps {
isOpen: boolean;
onClose: () => void;
platformVisibility: () => PlatformNavigationVisibility;
patrolVisible: () => boolean;
}
const UNIFIED_NAV_SHORTCUTS: ShortcutGroup = {
@@ -31,6 +32,8 @@ const UNIFIED_NAV_SHORTCUTS: ShortcutGroup = {
],
};
const PATROL_NAV_SHORTCUT_KEYS = 'g then r';
const NAV_PRIMARY_SHORTCUTS: Record<string, keyof PlatformNavigationVisibility> = {
'g then s': 'standalone',
'g then p': 'proxmox',
@@ -54,6 +57,7 @@ export function KeyboardShortcutsModal(props: KeyboardShortcutsModalProps) {
const shortcutGroups = createMemo<ShortcutGroup[]>(() => {
const platformVisibility = props.platformVisibility();
const visibleNavigationItems = UNIFIED_NAV_SHORTCUTS.items.filter((item) => {
if (item.keys === PATROL_NAV_SHORTCUT_KEYS) return props.patrolVisible();
const navId = NAV_PRIMARY_SHORTCUTS[item.keys];
if (!navId) return true;
return primaryPlatformNavigationIsVisible(platformVisibility, navId);
@@ -50,10 +50,12 @@ describe('CommandPaletteModal', () => {
cleanup();
navigateMock.mockReset();
vi.restoreAllMocks();
aiChatStore.setEnabled(false);
});
beforeEach(() => {
window.history.replaceState({}, '', '/proxmox/overview');
aiChatStore.setEnabled(true);
});
it('keeps the command palette on shell, runtime, and model owners', () => {
@@ -479,6 +481,22 @@ describe('CommandPaletteModal', () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it('offers neither Patrol nor Assistant commands while AI is off (#905)', () => {
aiChatStore.setEnabled(false);
render(() => (
<CommandPaletteModal
isOpen={true}
onClose={vi.fn()}
platformVisibility={platformVisibility}
/>
));
expect(screen.getByText('Go to Alerts')).toBeInTheDocument();
expect(screen.getByText('Go to Settings')).toBeInTheDocument();
expect(screen.queryByText('Go to Patrol')).not.toBeInTheDocument();
expect(screen.queryByText('Show Assistant commands')).not.toBeInTheDocument();
});
it('hides platform commands without supported infrastructure evidence', () => {
render(() => (
<CommandPaletteModal
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@solidjs/testing-library';
import type { JSX } from 'solid-js';
import { createEmptyPlatformNavigationVisibility } from '@/features/platformNavigation/platformNavigationModel';
vi.mock('@/components/shared/Dialog', () => ({
Dialog: (props: { isOpen: boolean; children: JSX.Element }) =>
props.isOpen ? <div>{props.children}</div> : null,
}));
import { KeyboardShortcutsModal } from '@/components/shared/KeyboardShortcutsModal';
describe('KeyboardShortcutsModal', () => {
afterEach(() => {
cleanup();
});
const renderModal = (patrolVisible: boolean) =>
render(() => (
<KeyboardShortcutsModal
isOpen={true}
onClose={vi.fn()}
platformVisibility={createEmptyPlatformNavigationVisibility}
patrolVisible={() => patrolVisible}
/>
));
it('lists the Patrol shortcut while Patrol navigation is visible', () => {
renderModal(true);
expect(screen.getByText('Go to Patrol')).toBeInTheDocument();
expect(screen.getByText('Go to Alerts')).toBeInTheDocument();
});
it('drops the Patrol shortcut while AI is off (#905)', () => {
renderModal(false);
expect(screen.queryByText('Go to Patrol')).not.toBeInTheDocument();
expect(screen.getByText('Go to Alerts')).toBeInTheDocument();
expect(screen.getByText('Go to Settings')).toBeInTheDocument();
});
});
@@ -49,6 +49,8 @@ export type CommandPaletteAssistantOpenPresentation = {
export function buildCommandPaletteCommands(options: {
paths: CommandPaletteCommandPaths;
platformVisibility: PlatformNavigationVisibility;
// Omitted means visible, for callers that do not gate Patrol.
patrolVisible?: boolean;
navigate: (path: string) => void;
assistantActions?: CommandPaletteAssistantActions;
assistantOpenPresentation?: CommandPaletteAssistantOpenPresentation;
@@ -242,32 +244,34 @@ export function buildCommandPaletteCommands(options: {
});
}
commands.push(
{
id: 'nav-alerts',
label: 'Go to Alerts',
description: '/alerts',
shortcut: 'g a',
keywords: ['alarms', 'notifications'],
action: () => options.navigate('/alerts'),
},
{
commands.push({
id: 'nav-alerts',
label: 'Go to Alerts',
description: '/alerts',
shortcut: 'g a',
keywords: ['alarms', 'notifications'],
action: () => options.navigate('/alerts'),
});
if (options.patrolVisible !== false) {
commands.push({
id: 'nav-patrol',
label: 'Go to Patrol',
description: '/patrol',
shortcut: 'g r',
keywords: ['needs attention', 'patrol', 'findings', 'ai', 'verification'],
action: () => options.navigate('/patrol'),
},
{
id: 'nav-settings',
label: 'Go to Settings',
description: '/settings',
shortcut: 'g t',
keywords: ['preferences', 'config'],
action: () => options.navigate('/settings'),
},
);
});
}
commands.push({
id: 'nav-settings',
label: 'Go to Settings',
description: '/settings',
shortcut: 'g t',
keywords: ['preferences', 'config'],
action: () => options.navigate('/settings'),
});
return commands;
}
@@ -36,6 +36,10 @@ export function useCommandPaletteState(props: CommandPaletteModalProps) {
runAfterPaletteSelection(() => aiChatStore.requestCommand(action));
};
// Same session capability that gates the Assistant launcher and the Patrol
// tab: with AI off, the palette offers neither Assistant nor Patrol.
const aiAvailable = () => aiChatStore.enabled === true;
const commands = createMemo(() =>
buildCommandPaletteCommands({
paths: {
@@ -49,23 +53,26 @@ export function useCommandPaletteState(props: CommandPaletteModalProps) {
vmwareNetworksPath: buildVmwarePath('networks'),
},
platformVisibility: props.platformVisibility(),
patrolVisible: aiAvailable(),
navigate,
assistantOpenPresentation: {
label: assistantPageContext().commandLabel,
description: assistantPageContext().commandDescription,
},
assistantActions: {
open: () =>
runAfterPaletteSelection(() => aiChatStore.open(assistantPageContext().context)),
help: () => requestAssistantCommand('help'),
newSession: () => requestAssistantCommand('new'),
sessions: () => requestAssistantCommand('sessions'),
models: () => requestAssistantCommand('models'),
providers: () => requestAssistantCommand('providers'),
status: () => requestAssistantCommand('status'),
undo: () => requestAssistantCommand('undo'),
redo: () => requestAssistantCommand('redo'),
},
assistantActions: aiAvailable()
? {
open: () =>
runAfterPaletteSelection(() => aiChatStore.open(assistantPageContext().context)),
help: () => requestAssistantCommand('help'),
newSession: () => requestAssistantCommand('new'),
sessions: () => requestAssistantCommand('sessions'),
models: () => requestAssistantCommand('models'),
providers: () => requestAssistantCommand('providers'),
status: () => requestAssistantCommand('status'),
undo: () => requestAssistantCommand('undo'),
redo: () => requestAssistantCommand('redo'),
}
: undefined,
}),
);
@@ -4,6 +4,7 @@ import actionsSource from '@/pages/Actions.tsx?raw';
import { getActionsWatchOnlyEmptyState } from '../actionPresentation';
const base = {
aiEnabled: true,
patrolWatchOnly: true,
patrolModesUnlocked: false,
commercialSurfacesHidden: false,
@@ -17,6 +18,14 @@ describe('getActionsWatchOnlyEmptyState', () => {
expect(actionsSource).toContain('estimatedItemHeight={92}');
});
it('returns nothing while AI is off, so Patrol is not described as running (#905)', () => {
expect(getActionsWatchOnlyEmptyState({ ...base, aiEnabled: false })).toBeUndefined();
expect(
getActionsWatchOnlyEmptyState({ ...base, aiEnabled: false, patrolModesUnlocked: true }),
).toBeUndefined();
expect(actionsSource).toContain('aiEnabled: aiChatStore.enabled === true');
});
it('returns nothing when Patrol is not in Watch only', () => {
expect(getActionsWatchOnlyEmptyState({ ...base, patrolWatchOnly: false })).toBeUndefined();
expect(
@@ -119,6 +119,9 @@ export const getActionApprovalBadgePresentation = (
};
export interface ActionsWatchOnlyEmptyStateInput {
// False while AI is off or unconfigured: Patrol is not running at all, so
// naming its Watch only mode (or pitching Pro modes) would be untrue.
aiEnabled: boolean;
patrolWatchOnly: boolean;
patrolModesUnlocked: boolean;
commercialSurfacesHidden: boolean;
@@ -136,7 +139,7 @@ export type ActionsWatchOnlyEmptyStatePresentation =
export const getActionsWatchOnlyEmptyState = (
input: ActionsWatchOnlyEmptyStateInput,
): ActionsWatchOnlyEmptyStatePresentation | undefined => {
if (!input.patrolWatchOnly) return undefined;
if (!input.aiEnabled || !input.patrolWatchOnly) return undefined;
if (input.patrolModesUnlocked) {
return {
kind: 'switch',
@@ -27,6 +27,9 @@ type KeyboardShortcutsOptions = {
onToggleCommandPalette?: () => void;
onFocusSearch?: () => boolean | void;
platformVisibility?: Accessor<PlatformNavigationVisibility>;
// Patrol navigation exists only while AI is enabled and configured; an
// absent accessor keeps the shortcut for callers that do not gate it.
patrolVisible?: Accessor<boolean>;
};
const isEditableTarget = (target: EventTarget | null): boolean => {
@@ -99,7 +102,7 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
return {
...infrastructureRoutes,
a: '/alerts',
r: '/patrol',
...(options.patrolVisible?.() === false ? {} : { r: '/patrol' }),
t: '/settings',
};
};
+3 -3
View File
@@ -491,8 +491,8 @@ export const DE_MESSAGE_OVERRIDES = {
'Behalten Sie Ihre Systeme von ueberall im Blick und erhalten Sie Alarm-Push-Benachrichtigungen ueber die Pulse-Mobile-App — ohne Portfreigaben oder VPN.',
'settings.header.systemRelay.title': 'Remote-Zugriff',
'settings.header.systemUpdates.description':
'Verwalten Sie Versionspruefungen, Update-Kanaele und automatische Updates der Pulse-Server-Laufzeit. Agent-Updates bleiben unter Infrastruktur.',
'settings.header.systemUpdates.title': 'Pulse-Server-Updates',
'Suchen Sie nach neuen Pulse-Versionen, waehlen Sie einen Update-Kanal und verwalten Sie automatische Updates. Agent-Updates bleiben unter Infrastruktur.',
'settings.header.systemUpdates.title': 'Updates',
'settings.header.supportDiagnostics.description':
'Fuehren Sie Zustandspruefungen aus, validieren Sie Verbindungen und exportieren Sie Troubleshooting-Snapshots.',
'settings.header.supportDiagnostics.title': 'Diagnose & Zustand',
@@ -536,7 +536,7 @@ export const DE_MESSAGE_OVERRIDES = {
'settings.nav.item.sharing': 'Freigabe',
'settings.nav.item.singleSignOn': 'Single Sign-On',
'settings.nav.item.systemLogs': 'Systemprotokolle',
'settings.nav.item.updates': 'Pulse-Server-Updates',
'settings.nav.item.updates': 'Updates',
'settings.nav.item.users': 'Benutzer',
'settings.shell.collapseSidebarLabel': 'Einstellungsnavigation einklappen',
'settings.shell.configurationLoading': 'Konfiguration wird geladen...',
+3 -3
View File
@@ -484,8 +484,8 @@ export const ES_MESSAGE_OVERRIDES = {
'Consulta tus sistemas y recibe notificaciones push de alertas desde cualquier lugar con la aplicación Pulse Mobile — sin abrir puertos ni VPN.',
'settings.header.systemRelay.title': 'Acceso remoto',
'settings.header.systemUpdates.description':
'Administra las comprobaciones de versión, los canales y las actualizaciones automáticas del servidor Pulse. Las actualizaciones de agentes permanecen en Infraestructura.',
'settings.header.systemUpdates.title': 'Actualizaciones del servidor Pulse',
'Busca nuevas versiones de Pulse, elige un canal de actualización y administra las actualizaciones automáticas. Las actualizaciones de agentes permanecen en Infraestructura.',
'settings.header.systemUpdates.title': 'Actualizaciones',
'settings.header.supportDiagnostics.description':
'Ejecuta comprobaciones de salud, valida conectividad y exporta snapshots de resolución de problemas.',
'settings.header.supportDiagnostics.title': 'Diagnóstico y salud',
@@ -529,7 +529,7 @@ export const ES_MESSAGE_OVERRIDES = {
'settings.nav.item.sharing': 'Uso compartido',
'settings.nav.item.singleSignOn': 'Inicio de sesión único',
'settings.nav.item.systemLogs': 'Logs del sistema',
'settings.nav.item.updates': 'Actualizaciones del servidor Pulse',
'settings.nav.item.updates': 'Actualizaciones',
'settings.nav.item.users': 'Usuarios',
'settings.shell.collapseSidebarLabel': 'Contraer navegación de ajustes',
'settings.shell.configurationLoading': 'Cargando configuración...',
+3 -3
View File
@@ -474,8 +474,8 @@ export const EN_MESSAGES = {
'Check on your systems and get alert push notifications anywhere with the Pulse Mobile app — no port forwarding or VPN required.',
'settings.header.systemRelay.title': 'Remote Access',
'settings.header.systemUpdates.description':
'Manage Pulse server runtime version checks, update channels, and automatic updates. Agent updates stay under Infrastructure.',
'settings.header.systemUpdates.title': 'Pulse server updates',
'Check for new Pulse versions, choose an update channel, and manage automatic updates. Agent updates stay under Infrastructure.',
'settings.header.systemUpdates.title': 'Updates',
'settings.header.supportDiagnostics.description':
'Run health checks, validate connectivity, and export troubleshooting snapshots.',
'settings.header.supportDiagnostics.title': 'Diagnostics & Health',
@@ -519,7 +519,7 @@ export const EN_MESSAGES = {
'settings.nav.item.sharing': 'Sharing',
'settings.nav.item.singleSignOn': 'Single Sign-On',
'settings.nav.item.systemLogs': 'System Logs',
'settings.nav.item.updates': 'Pulse server updates',
'settings.nav.item.updates': 'Updates',
'settings.nav.item.users': 'Users',
'settings.shell.collapseSidebarLabel': 'Collapse settings navigation',
'settings.shell.configurationLoading': 'Loading configuration...',
+2
View File
@@ -10,6 +10,7 @@ import { Card } from '@/components/shared/Card';
import { UpgradeButtonLink } from '@/components/shared/UpgradeLink';
import { PATROL_AUTONOMY_FEATURE_KEY } from '@/features/patrol/patrolAutonomyAvailability';
import { PATROL_PATH } from '@/routing/resourceLinks';
import { aiChatStore } from '@/stores/aiChat';
import { hasFeature } from '@/stores/license';
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
import {
@@ -66,6 +67,7 @@ export function Actions() {
const watchOnlyGuidance = createMemo(() =>
getActionsWatchOnlyEmptyState({
aiEnabled: aiChatStore.enabled === true,
patrolWatchOnly: patrolWatchOnly(),
patrolModesUnlocked: hasFeature(PATROL_AUTONOMY_FEATURE_KEY),
commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(),
+1 -1
View File
@@ -105,7 +105,7 @@ export interface SecurityStatusSettingsCapabilities {
reportingRead: boolean;
/**
* The same RequireAdmin + settings:read gate as infrastructureRead, scoped to
* the System > Network / Pulse server updates / Recovery tabs. A sibling
* the System > Network / Updates / Recovery tabs. A sibling
* rather than a reuse, so tightening one surface's gate cannot silently hide
* the other's tabs.
*/
@@ -21,9 +21,8 @@ describe('updatesPresentation', () => {
it('returns canonical updates panel framing copy', () => {
expect(UPDATES_PANEL_COPY).toEqual({
title: 'Pulse server updates',
description:
'Manage the Pulse server runtime. Pulse Agent updates are diagnosed under Infrastructure.',
title: 'Updates',
description: 'Keep Pulse up to date. Agent updates are diagnosed under Infrastructure.',
currentVersionLabel: 'Server version',
checkNowLabel: 'Check Now',
checkingLabel: 'Checking...',
@@ -7,9 +7,8 @@ export interface UpdateBuildBadge {
}
export const UPDATES_PANEL_COPY = {
title: 'Pulse server updates',
description:
'Manage the Pulse server runtime. Pulse Agent updates are diagnosed under Infrastructure.',
title: 'Updates',
description: 'Keep Pulse up to date. Agent updates are diagnosed under Infrastructure.',
currentVersionLabel: 'Server version',
checkNowLabel: 'Check Now',
checkingLabel: 'Checking...',
@@ -61,9 +61,9 @@ const SETTINGS_SHELL_ROUTES = [
},
{
route: '/settings/system-updates',
title: 'Pulse server updates',
title: 'Updates',
description:
'Manage Pulse server runtime version checks, update channels, and automatic updates. Agent updates stay under Infrastructure.',
'Check for new Pulse versions, choose an update channel, and manage automatic updates. Agent updates stay under Infrastructure.',
},
{
route: '/settings/system-recovery',