mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Polish assistant shell modal behavior
This commit is contained in:
parent
9c907b22bf
commit
9352533d70
18 changed files with 141 additions and 20 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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')");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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'");
|
||||
|
|
|
|||
|
|
@ -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(() => (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue