Polish assistant shell modal behavior

This commit is contained in:
rcourtman 2026-04-15 11:46:47 +01:00
parent 9c907b22bf
commit 9352533d70
18 changed files with 141 additions and 20 deletions

View file

@ -233,6 +233,12 @@ other header-adjacent notices must not fork assistant open state, gate on AI
runtime fetches, or move assistant availability logic out of
`frontend-modern/src/stores/aiChat.ts` and `frontend-modern/src/useAppRuntimeState.ts`
just because they share the same authenticated shell.
That same shared shell boundary must respect blocking modal ownership.
`frontend-modern/src/App.tsx` and `frontend-modern/src/AppLayout.tsx` may use
the shared dialog runtime to hide the closed assistant launcher and close the
drawer while a blocking shared dialog is open, but they must not leave Pulse
Assistant interactive behind a modal or fork a second assistant-open state
model to do it.
That same shared shell rule applies when presentation policy suppresses hosted
organization chrome: `frontend-modern/src/App.tsx` and
`frontend-modern/src/AppLayout.tsx` may hide org switchers or demo-only org
@ -284,9 +290,9 @@ helpers used across chat, settings, and usage surfaces.
`frontend-modern/src/utils/aiSessionDiffPresentation.ts`, and
`frontend-modern/src/utils/aiExplorePresentation.ts` are the canonical owners
for provider naming, provider health labels, control-level semantics,
chat drawer title/subtitle, discovery hint framing, chat/session empty states,
assistant message and question-card labels, session-diff badges, and explore-
status labels.
chat drawer title/subtitle, launcher title/aria copy, session-menu labeling,
discovery hint framing, chat/session empty states, assistant message and
question-card labels, session-diff badges, and explore-status labels.
Settings and chat surfaces must consume those helpers instead of keeping local
AI wording or model/provider inference branches.

View file

@ -950,6 +950,9 @@ theme synchronization, and authenticated runtime startup, and
as org switching and kiosk-safe navigation. Future hosted browser bootstrap
work must extend that split rather than pulling org bootstrap and app chrome
back into one monolithic route component.
That same authenticated shell split must also respect shared blocking dialogs:
hosted chrome may not leave the Pulse Assistant launcher or an already-open
assistant drawer interactive behind a modal that currently owns the viewport.
That same authenticated shell contract now also has to distinguish backend
availability from websocket-stream liveness. When hosted runtime health stays
available during a stream reconnect or renegotiation window,

View file

@ -265,6 +265,11 @@ work extends shared components instead of creating new local variants.
shared drawer shell owner for assistant open/close state, focus handoff,
and tenant-local context/session persistence; the app shell must not fork
that state across `App.tsx`, `AppLayout.tsx`, or page-level helpers.
The same shared shell boundary must keep Pulse Assistant coherent while a
blocking shared dialog owns the viewport: closed launcher affordances must
hide until the dialog clears, and the shell must close any already-open
assistant drawer instead of leaving background assistant controls visibly
active behind the modal.
AI-owned frontend surfaces that need shared settings or model-catalog
truth must route those reads through
`frontend-modern/src/stores/aiRuntimeState.ts` rather than each feature
@ -1063,11 +1068,16 @@ pushing tone or active-card presentation logic back into the shell.
The shared dialog now follows that same owner split.
`frontend-modern/src/components/shared/Dialog.tsx` stays the render shell,
`frontend-modern/src/components/shared/useDialogState.ts` owns focus trap,
body-scroll lock, previous-focus restoration, and backdrop-close runtime, and
body-scroll lock, previous-focus restoration, shared blocking-dialog
visibility, and backdrop-close runtime, and
`frontend-modern/src/components/shared/dialogModel.ts` owns focusable-element
lookup plus layout and panel class policy. Future dialog work should extend
those owners instead of pushing focus-trap lifecycle or layout policy back into
the shared shell.
App-shell consumers such as `frontend-modern/src/App.tsx` and
`frontend-modern/src/AppLayout.tsx` may read that shared blocking-dialog state
to suppress background affordances, but they must not reimplement their own
parallel modal-stack bookkeeping.
The shared history chart now follows the same owner shape.
`frontend-modern/src/components/shared/HistoryChart.tsx` stays the render
shell, `frontend-modern/src/components/shared/useHistoryChartState.ts` owns

View file

@ -246,7 +246,7 @@ regression protection.
the same compact summary payload instead of reintroducing route-local
`all-resources` fetches, summary recomputation, or page-level layout churn
that displaces the protected overview widgets.
32. Keep infrastructure summary consumers on the compact dashboard overview rather than reopening the all-resources hook. `frontend-modern/src/hooks/useDashboardTrends.ts`, `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`, and adjacent dashboard summary consumers may derive chart identity and storage presence from the overview payload they were already given, but they must not call `useResources()` or mount a second unfiltered unified-resource fetch path inside the dashboard hot path. That rule also applies to globally mounted helpers such as `frontend-modern/src/components/AI/Chat/index.tsx`: closed assistant surfaces must read the live websocket snapshot or existing unified-resource cache rather than forcing the dashboard to pay for `all-resources` just because the shell component is mounted. When that assistant shell changes presentation, `frontend-modern/src/utils/aiChatPresentation.ts` must remain the canonical owner for drawer labels and empty-state copy so hot-path consumers do not grow one-off inline strings or extra state branches alongside the mounted shell.
32. Keep infrastructure summary consumers on the compact dashboard overview rather than reopening the all-resources hook. `frontend-modern/src/hooks/useDashboardTrends.ts`, `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`, and adjacent dashboard summary consumers may derive chart identity and storage presence from the overview payload they were already given, but they must not call `useResources()` or mount a second unfiltered unified-resource fetch path inside the dashboard hot path. That rule also applies to globally mounted helpers such as `frontend-modern/src/components/AI/Chat/index.tsx`: closed assistant surfaces must read the live websocket snapshot or existing unified-resource cache rather than forcing the dashboard to pay for `all-resources` just because the shell component is mounted. When that assistant shell changes presentation, `frontend-modern/src/utils/aiChatPresentation.ts` must remain the canonical owner for launcher, drawer, session-menu, and empty-state copy so hot-path consumers do not grow one-off inline strings or extra state branches alongside the mounted shell. Blocking shared dialogs must also suppress closed assistant affordances through the shared dialog runtime instead of leaving the mounted shell clickable behind another overlay.
33. Keep hidden workload-route selector shells off the hot path. When the
workloads route keeps `frontend-modern/src/components/shared/InfrastructureSelector.tsx`
mounted only for layout parity, `frontend-modern/src/components/shared/useInfrastructureSelectorState.ts`

View file

@ -266,6 +266,10 @@ querying, and the operator-facing storage health presentation layer.
may hide top-bar org chrome for public demo posture, but it must not leak
into storage/recovery preview route ownership, first-session recovery copy,
or route-level framing decisions.
That same shared app-shell boundary must also respect blocking shared
dialogs: background assistant affordances may hide while a modal owns the
viewport, but storage/recovery routes must not grow their own parallel
modal-stack bookkeeping just because they share `App.tsx`.
36. Keep public self-hosted purchase handoff and activation routes on the
adjacent commercial/auth boundary. When `internal/api/router.go`,
`internal/api/router_routes_cloud.go`, `internal/api/licensing_handlers.go`,

View file

@ -22,6 +22,7 @@ import { MonitoredSystemLimitWarningBanner } from './components/shared/Monitored
import { WhatsNewModal } from './components/shared/WhatsNewModal';
import { KeyboardShortcutsModal } from './components/shared/KeyboardShortcutsModal';
import { CommandPaletteModal } from './components/shared/CommandPaletteModal';
import { dialogStackHasBlockingDialog } from './components/shared/useDialogState';
import { createTooltipSystem } from './components/shared/Tooltip';
import { TokenRevealDialog } from './components/TokenRevealDialog';
import { UpdateProgressModal } from './components/UpdateProgressModal';
@ -304,6 +305,12 @@ function App() {
onCloseCommandPalette: () => setCommandPaletteOpen(false),
});
createEffect(() => {
if (dialogStackHasBlockingDialog() && aiChatStore.isOpenSignal()) {
aiChatStore.close();
}
});
// Setup escape handling for the assistant drawer.
onMount(() => {
const handleKeyDown = (e: KeyboardEvent) => {

View file

@ -21,6 +21,7 @@ import Minimize2Icon from 'lucide-solid/icons/minimize-2';
import ActivityIcon from 'lucide-solid/icons/activity';
import { MobileNavBar } from '@/components/shared/MobileNavBar';
import { ReleaseCandidateBanner } from '@/components/shared/ReleaseCandidateBanner';
import { dialogStackHasBlockingDialog } from '@/components/shared/useDialogState';
import { OrgSwitcher } from '@/components/OrgSwitcher';
import { PulsePatrolLogo } from '@/components/Brand/PulsePatrolLogo';
import { MONITORING_READ_SCOPE } from '@/constants/apiScopes';
@ -44,6 +45,10 @@ import {
presentationPolicyHidesUpgradePrompts,
presentationPolicyIsDemoMode,
} from '@/stores/sessionPresentationPolicy';
import {
AI_CHAT_LAUNCHER_ARIA_LABEL,
getAIChatLauncherTitle,
} from '@/utils/aiChatPresentation';
import type { AppConnectionStatus } from '@/useAppRuntimeState';
const ROOT_INFRASTRUCTURE_PATH = buildInfrastructurePath();
@ -812,17 +817,20 @@ export function AppLayout(props: AppLayoutProps) {
</footer>
</Show>
<Show when={aiChatStore.enabled === true && !aiChatStore.isOpenSignal() && !kioskMode()}>
<Show
when={
aiChatStore.enabled === true &&
!aiChatStore.isOpenSignal() &&
!kioskMode() &&
!dialogStackHasBlockingDialog()
}
>
<button
type="button"
onClick={() => aiChatStore.toggle()}
class="fixed right-0 top-1/2 -translate-y-1/2 z-40 flex min-h-10 sm:min-h-9 min-w-10 items-center justify-center px-2.5 py-2.5 rounded-l-xl bg-blue-600 text-white shadow-sm hover:bg-blue-700 transition-colors duration-200 group sm:top-1/2 sm:translate-y-[-50%] top-auto bottom-[calc(5rem+env(safe-area-inset-bottom,0px))] translate-y-0"
title={
aiChatStore.context.context?.name
? `Pulse Assistant - ${aiChatStore.context.context.name}`
: 'Pulse Assistant (⌘K)'
}
aria-label="Expand Pulse Assistant"
title={getAIChatLauncherTitle(aiChatStore.context.context?.name)}
aria-label={AI_CHAT_LAUNCHER_ARIA_LABEL}
>
<svg
class="h-5 w-5 flex-shrink-0"

View file

@ -13,6 +13,9 @@ describe('App architecture', () => {
"import { DarkModeContext, WebSocketContext, useWebSocket } from '@/contexts/appRuntime';",
);
expect(appSource).toContain("import { useAppRuntimeState } from '@/useAppRuntimeState';");
expect(appSource).toContain(
"import { dialogStackHasBlockingDialog } from './components/shared/useDialogState';",
);
expect(appSource).toContain("import {");
expect(appSource).toContain("} from '@/utils/appShellScrollRestoration';");
expect(appSource).toContain('const runtime = useAppRuntimeState();');
@ -47,6 +50,7 @@ describe('App architecture', () => {
expect(appSource).not.toContain('const [activeOrgID, setActiveOrgID] = createSignal(');
expect(appSource).not.toContain("import('./api/ai')");
expect(appSource).not.toContain('AIAPI.getSettings()');
expect(appSource).toContain('if (dialogStackHasBlockingDialog() && aiChatStore.isOpenSignal()) {');
expect(appSource).toContain("if (e.key === 'Escape' && aiChatStore.isOpen) {");
expect(appSource).toContain('<AIChat onClose={() => aiChatStore.close()} />');
expect(appSource).toContain('showOrgSwitcher={runtime.showOrgSwitcher}');
@ -55,6 +59,9 @@ describe('App architecture', () => {
it('keeps authenticated chrome in AppLayout and hosted bootstrap in useAppRuntimeState', () => {
expect(appLayoutSource).toContain('export function AppLayout(props: AppLayoutProps)');
expect(appLayoutSource).toContain("import { aiChatStore } from '@/stores/aiChat';");
expect(appLayoutSource).toContain(
"import { dialogStackHasBlockingDialog } from '@/components/shared/useDialogState';",
);
expect(appLayoutSource).toContain(
"import { ReleaseCandidateBanner } from '@/components/shared/ReleaseCandidateBanner';",
);
@ -83,9 +90,12 @@ describe('App architecture', () => {
expect(appLayoutSource).toContain('presentationPolicyIsDemoMode');
expect(appLayoutSource).toContain("if (!presentationPolicyIsDemoMode()) {");
expect(appLayoutSource).toContain(
'aiChatStore.enabled === true && !aiChatStore.isOpenSignal() && !kioskMode()',
'aiChatStore.enabled === true &&',
);
expect(appLayoutSource).toContain('!dialogStackHasBlockingDialog()');
expect(appLayoutSource).toContain('onClick={() => aiChatStore.toggle()}');
expect(appLayoutSource).toContain('getAIChatLauncherTitle');
expect(appLayoutSource).not.toContain('Pulse Assistant (⌘K)');
expect(appSource).not.toContain("eventBus.on('theme_changed'");
expect(appSource).not.toContain("eventBus.on('websocket_reconnected'");
expect(appSource).not.toContain("apiFetch('/api/security/status')");

View file

@ -431,13 +431,13 @@ describe('AIChat', () => {
it('opens session picker on click', () => {
renderChat();
fireEvent.click(screen.getByTitle('Chat sessions'));
fireEvent.click(screen.getByTitle('Pulse Assistant sessions'));
expect(screen.getByText('New session')).toBeInTheDocument();
});
it('shows "No previous assistant sessions" when empty', () => {
renderChat();
fireEvent.click(screen.getByTitle('Chat sessions'));
fireEvent.click(screen.getByTitle('Pulse Assistant sessions'));
expect(screen.getByText('No previous assistant sessions')).toBeInTheDocument();
});
@ -450,7 +450,7 @@ describe('AIChat', () => {
await waitFor(() => {
expect(mockAIChatAPI.listSessions).toHaveBeenCalled();
});
fireEvent.click(screen.getByTitle('Chat sessions'));
fireEvent.click(screen.getByTitle('Pulse Assistant sessions'));
expect(screen.getByText('Session One')).toBeInTheDocument();
expect(screen.getByText('Session Two')).toBeInTheDocument();
expect(screen.getByText('5 messages')).toBeInTheDocument();
@ -465,7 +465,7 @@ describe('AIChat', () => {
await waitFor(() => {
expect(mockAIChatAPI.listSessions).toHaveBeenCalled();
});
fireEvent.click(screen.getByTitle('Chat sessions'));
fireEvent.click(screen.getByTitle('Pulse Assistant sessions'));
fireEvent.click(screen.getByText('Session One'));
expect(mockChat.loadSession).toHaveBeenCalledWith('s1');
});
@ -1026,7 +1026,7 @@ describe('AIChat', () => {
await waitFor(() => {
expect(mockAIChatAPI.listSessions).toHaveBeenCalled();
});
fireEvent.click(screen.getByTitle('Chat sessions'));
fireEvent.click(screen.getByTitle('Pulse Assistant sessions'));
expect(screen.getByText('Session One')).toBeInTheDocument();
// Find the delete button inside the session row

View file

@ -35,6 +35,7 @@ import {
AI_CHAT_NEW_SESSION_BUTTON_TITLE,
AI_CHAT_NEW_SESSION_MENU_LABEL,
AI_CHAT_NEW_SESSION_SHORT_LABEL,
AI_CHAT_SESSION_MENU_TITLE,
AI_CHAT_SESSION_EMPTY_STATE,
getAIChatEmptyStateSuggestions,
} from '@/utils/aiChatPresentation';
@ -978,7 +979,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
setShowSessions(next);
}}
class="p-2 hover:text-base-content rounded-md hover:bg-surface-hover transition-colors"
title="Chat sessions"
title={AI_CHAT_SESSION_MENU_TITLE}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path

View file

@ -1085,10 +1085,12 @@ describe('monitored-system model guardrails', () => {
expect(aiChatSource).not.toContain("mention.type === 'agent'");
expect(aiChatSource).toContain('@/utils/aiControlLevelPresentation');
expect(aiChatSource).toContain('AI_CHAT_SESSION_EMPTY_STATE');
expect(aiChatSource).toContain('AI_CHAT_SESSION_MENU_TITLE');
expect(aiChatSource).toContain('AI_CHAT_DRAWER_SUBTITLE');
expect(aiChatSource).toContain('AI_CHAT_DISCOVERY_HINT_TITLE');
expect(aiChatSource).toContain('getAIChatEmptyStateSuggestions');
expect(aiChatSource).not.toContain('No previous assistant sessions');
expect(aiChatSource).not.toContain('Chat sessions');
expect(aiChatSource).not.toContain('Infrastructure intelligence');
expect(aiChatSource).not.toContain('Pulse Assistant ready');
expect(aiChatSource).not.toContain('Discovery is off.');
@ -1105,9 +1107,11 @@ describe('monitored-system model guardrails', () => {
'const mentionsForAPI = mentions.length > 0 ? mentions : undefined;',
);
expect(aiChatPresentationSource).toContain('export const AI_CHAT_SESSION_EMPTY_STATE');
expect(aiChatPresentationSource).toContain('export const AI_CHAT_SESSION_MENU_TITLE');
expect(aiChatPresentationSource).toContain('export const AI_CHAT_DRAWER_SUBTITLE');
expect(aiChatPresentationSource).toContain('export const AI_CHAT_DISCOVERY_HINT_TITLE');
expect(aiChatPresentationSource).toContain('export const AI_CHAT_QUESTION_CARD_TITLE');
expect(aiChatPresentationSource).toContain('export function getAIChatLauncherTitle');
expect(aiChatPresentationSource).toContain('export function getAIChatEmptyStateSuggestions');
expect(mentionAutocompleteSource).toContain("| 'agent'");
expect(mentionAutocompleteSource).toContain('getSimpleStatusIndicator');

View file

@ -1177,6 +1177,13 @@ describe('shared primitive guardrails', () => {
expect(whatsNewModalModelSource).toContain("title: 'Infrastructure'");
});
it('keeps dialog stack visibility in the shared dialog runtime', () => {
expect(dialogStateSource).toContain('export function dialogStackHasBlockingDialog');
expect(dialogStateSource).toContain('createSignal');
expect(dialogStateSource).toContain('openDialogCount');
expect(dialogStateSource).toContain('document.body.style.overflow');
});
it('keeps summary density control inside the shared summary primitives', () => {
expect(summaryPanelSource).toContain("density?: 'default' | 'compact'");
expect(summaryPanelSource).toContain("props.density === 'compact'");

View file

@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { Dialog } from '@/components/shared/Dialog';
import { dialogStackHasBlockingDialog } from '@/components/shared/useDialogState';
import dialogSource from '@/components/shared/Dialog.tsx?raw';
import dialogModelSource from '@/components/shared/dialogModel.ts?raw';
import dialogStateSource from '@/components/shared/useDialogState.ts?raw';
@ -27,6 +28,7 @@ describe('Dialog', () => {
expect(dialogStateSource).toContain('onCleanup');
expect(dialogStateSource).toContain('document.body.style.overflow');
expect(dialogStateSource).toContain('openDialogCount');
expect(dialogStateSource).toContain('dialogStackHasBlockingDialog');
expect(dialogStateSource).toContain('getDialogFocusableElements');
expect(dialogModelSource).toContain('export function getDialogLayout');
@ -71,6 +73,20 @@ describe('Dialog', () => {
expect(document.body.style.overflow).toBe('');
});
it('publishes blocking dialog state while a modal is open', () => {
expect(dialogStackHasBlockingDialog()).toBe(false);
const { unmount } = render(() => (
<Dialog isOpen={true} onClose={() => undefined}>
<div class="p-4">Body</div>
</Dialog>
));
expect(dialogStackHasBlockingDialog()).toBe(true);
unmount();
expect(dialogStackHasBlockingDialog()).toBe(false);
});
it('keeps keyboard focus trapped in the dialog', async () => {
const onClose = vi.fn();
render(() => (

View file

@ -1,4 +1,4 @@
import { createEffect, onCleanup } from 'solid-js';
import { createEffect, createSignal, onCleanup } from 'solid-js';
import type { Accessor } from 'solid-js';
import {
getDialogFocusableElements,
@ -15,6 +15,11 @@ interface DialogStateOptions {
let openDialogCount = 0;
let previousBodyOverflow = '';
const [dialogStackDepth, setDialogStackDepth] = createSignal(0);
export function dialogStackHasBlockingDialog() {
return dialogStackDepth() > 0;
}
function lockBodyScroll() {
if (typeof document === 'undefined') return;
@ -23,11 +28,13 @@ function lockBodyScroll() {
document.body.style.overflow = 'hidden';
}
openDialogCount += 1;
setDialogStackDepth(openDialogCount);
}
function unlockBodyScroll() {
if (typeof document === 'undefined') return;
openDialogCount = Math.max(0, openDialogCount - 1);
setDialogStackDepth(openDialogCount);
if (openDialogCount === 0) {
document.body.style.overflow = previousBodyOverflow;
}

View file

@ -8,12 +8,15 @@ import {
AI_CHAT_DRAWER_TITLE,
AI_CHAT_EMPTY_STATE_SUBTITLE,
AI_CHAT_EMPTY_STATE_TITLE,
AI_CHAT_LAUNCHER_ARIA_LABEL,
AI_CHAT_MODEL_SELECTOR_EMPTY_STATE,
AI_CHAT_NEW_SESSION_MENU_LABEL,
AI_CHAT_NEW_SESSION_SHORT_LABEL,
AI_CHAT_QUESTION_CARD_TITLE,
AI_CHAT_SESSION_EMPTY_STATE,
AI_CHAT_SESSION_MENU_TITLE,
AI_CHAT_SUGGESTIONS_LABEL,
getAIChatLauncherTitle,
getAIChatEmptyStateSuggestions,
} from '@/utils/aiChatPresentation';
@ -33,6 +36,8 @@ describe('aiChatPresentation', () => {
);
expect(AI_CHAT_NEW_SESSION_SHORT_LABEL).toBe('New');
expect(AI_CHAT_NEW_SESSION_MENU_LABEL).toBe('New session');
expect(AI_CHAT_LAUNCHER_ARIA_LABEL).toBe('Expand Pulse Assistant');
expect(AI_CHAT_SESSION_MENU_TITLE).toBe('Pulse Assistant sessions');
expect(AI_CHAT_SESSION_EMPTY_STATE).toBe('No previous assistant sessions');
expect(AI_CHAT_MODEL_SELECTOR_EMPTY_STATE).toBe('No matching models.');
expect(AI_CHAT_SUGGESTIONS_LABEL).toBe('Try asking');
@ -41,6 +46,12 @@ describe('aiChatPresentation', () => {
expect(AI_CHAT_CONTEXT_USED_LABEL).toBe('Context used');
});
it('builds canonical launcher titles without implying a keyboard shortcut', () => {
expect(getAIChatLauncherTitle()).toBe('Open Pulse Assistant');
expect(getAIChatLauncherTitle('Core Fabric')).toBe('Open Pulse Assistant for Core Fabric');
expect(getAIChatLauncherTitle()).not.toContain('⌘K');
});
it('builds canonical empty-state suggestions for cluster and single-system chat', () => {
expect(getAIChatEmptyStateSuggestions(true)).toEqual([
'Summarize cluster health',

View file

@ -4591,9 +4591,11 @@ describe('frontend resource type boundaries', () => {
expect(modelSelectorSource).not.toContain('No matching models.');
expect(aiChatPresentationSource).toContain('export const AI_CHAT_MODEL_SELECTOR_EMPTY_STATE');
expect(aiChatPresentationSource).toContain('export const AI_CHAT_SESSION_EMPTY_STATE');
expect(aiChatPresentationSource).toContain('export const AI_CHAT_SESSION_MENU_TITLE');
expect(aiChatPresentationSource).toContain('export const AI_CHAT_DRAWER_TITLE');
expect(aiChatPresentationSource).toContain('export const AI_CHAT_DISCOVERY_HINT_TITLE');
expect(aiChatPresentationSource).toContain('export const AI_CHAT_QUESTION_CARD_TITLE');
expect(aiChatPresentationSource).toContain('export function getAIChatLauncherTitle');
expect(aiChatPresentationSource).toContain('export function getAIChatEmptyStateSuggestions');
expect(remediationStatusSource).toContain('getRemediationPresentation');
expect(remediationStatusSource).not.toContain(

View file

@ -2,6 +2,8 @@ export const AI_CHAT_DRAWER_TITLE = 'Pulse Assistant';
export const AI_CHAT_DRAWER_SUBTITLE =
'Observed context, provider-backed reasoning, and governed actions.';
export const AI_CHAT_COLLAPSE_TITLE = 'Collapse Pulse Assistant';
export const AI_CHAT_LAUNCHER_ARIA_LABEL = 'Expand Pulse Assistant';
export const AI_CHAT_SESSION_MENU_TITLE = 'Pulse Assistant sessions';
export const AI_CHAT_NEW_SESSION_SHORT_LABEL = 'New';
export const AI_CHAT_NEW_SESSION_BUTTON_TITLE = 'New assistant session';
export const AI_CHAT_NEW_SESSION_MENU_LABEL = 'New session';
@ -32,6 +34,14 @@ const AI_CHAT_SINGLE_SYSTEM_EMPTY_STATE_SUGGESTIONS = [
'Explain recent Patrol findings',
];
export function getAIChatLauncherTitle(contextName?: unknown) {
if (typeof contextName === 'string' && contextName.trim().length > 0) {
return `Open Pulse Assistant for ${contextName}`;
}
return 'Open Pulse Assistant';
}
export function getAIChatEmptyStateSuggestions(isCluster: boolean) {
return isCluster
? AI_CHAT_CLUSTER_EMPTY_STATE_SUGGESTIONS

View file

@ -122,7 +122,9 @@ test.describe('Telemetry disclosure', () => {
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' });
const dialog = page.getByRole('dialog');
const assistantLauncher = page.getByRole('button', { name: 'Expand Pulse Assistant' });
await expect(dialog.getByText('Welcome to the New Navigation!')).toBeVisible();
await expect(assistantLauncher).toBeHidden();
await expect(
dialog.getByText(
/rotating install ID, normalized release identity, platform, resource counts, and feature flags/i,
@ -149,5 +151,18 @@ test.describe('Telemetry disclosure', () => {
'/docs/README.md',
'Welcome to the Pulse documentation portal.',
);
await dialog.getByRole('button', { name: "Let's go" }).click();
await expect(dialog).not.toBeVisible();
await expect(assistantLauncher).toBeVisible();
await assistantLauncher.click();
await expect(page.getByRole('heading', { name: 'Pulse Assistant' })).toBeVisible();
await expect(
page.getByText('Observed context, provider-backed reasoning, and governed actions.'),
).toBeVisible();
await page.getByTitle('Pulse Assistant sessions').click();
await expect(page.getByText('No previous assistant sessions')).toBeVisible();
});
});