diff --git a/docs/implplan/SPRINT_20260307_026_FE_global_assistant_drawer_unification.md b/docs/implplan/SPRINT_20260307_026_FE_global_assistant_drawer_unification.md index 83c48bf56..830410c35 100644 --- a/docs/implplan/SPRINT_20260307_026_FE_global_assistant_drawer_unification.md +++ b/docs/implplan/SPRINT_20260307_026_FE_global_assistant_drawer_unification.md @@ -19,7 +19,7 @@ ## Delivery Tracker ### FE-SC-001 - Host AdvisoryAI at shell scope -Status: TODO +Status: DONE Dependency: none Owners: Developer Task description: @@ -27,12 +27,12 @@ Task description: - Keep the drawer keyboard-accessible and reusable by page-level assistant affordances. Completion criteria: -- [ ] The application shell hosts the assistant drawer. -- [ ] Search-originated chat opens on the current page without navigating to `/security/triage`. -- [ ] Existing triage-specific assistant affordances either reuse the same host or are removed if redundant. +- [x] The application shell hosts the assistant drawer. +- [x] Search-originated chat opens on the current page without navigating to `/security/triage`. +- [x] Existing triage-specific assistant affordances either reuse the same host or are removed if redundant. ### FE-SC-002 - Remove visible search/assistant mode framing -Status: TODO +Status: DONE Dependency: FE-SC-001 Owners: Developer Task description: @@ -40,26 +40,27 @@ Task description: - Replace mode-driven prompt text with prompt composition based on current page, query, citations, and answer evidence. Completion criteria: -- [ ] Chat no longer displays mode buttons or mode badges. -- [ ] Search-to-chat handoff prompts are built from query/evidence context rather than a persisted mode. -- [ ] The result still gives useful next-step suggestions without requiring mode selection. +- [x] Chat no longer displays mode buttons or mode badges. +- [x] Search-to-chat handoff prompts are built from query/evidence context rather than a persisted mode. +- [x] The result still gives useful next-step suggestions without requiring mode selection. ### FE-SC-003 - Verify search-first assistant flows -Status: TODO +Status: DONE Dependency: FE-SC-002 Owners: Test Automation Task description: - Add unit and Playwright coverage proving that search is the primary entry and the assistant is a secondary deep-dive that stays on-page. Completion criteria: -- [ ] Unit tests cover the new shell-level assistant host behavior. -- [ ] Playwright covers top-bar chat launch, answer-panel Ask-AdvisoryAI, and result-card Ask-AdvisoryAI without route jumps. -- [ ] Search context inheritance remains intact in the assistant. +- [x] Unit tests cover the new shell-level assistant host behavior. +- [x] Playwright covers top-bar chat launch, answer-panel Ask-AdvisoryAI, and result-card Ask-AdvisoryAI without route jumps. +- [x] Search context inheritance remains intact in the assistant. ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | | 2026-03-07 | Sprint created from operator feedback on the split search/chat experience and the remaining route-jump behavior. | Project Manager | +| 2026-03-07 | Implemented shell-level assistant drawer, removed visible assistant modes, restored focus back to global search on close, and verified with targeted Angular tests plus live/mocked Playwright search flows. | Developer | ## Decisions & Risks - Decision: the global top-bar search is the canonical assistant launcher; route navigation to open chat is no longer acceptable for the primary flow. diff --git a/src/Web/StellaOps.Web/src/app/core/context/platform-context-url-sync.service.ts b/src/Web/StellaOps.Web/src/app/core/context/platform-context-url-sync.service.ts index 90eaae9ab..7375bf527 100644 --- a/src/Web/StellaOps.Web/src/app/core/context/platform-context-url-sync.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/context/platform-context-url-sync.service.ts @@ -32,39 +32,41 @@ export class PlatformContextUrlSyncService { ) .subscribe(() => this.applyScopeFromUrl()); - effect( - () => { - this.context.contextVersion(); - if (!this.context.initialized() || this.syncingFromUrl) { - return; - } + queueMicrotask(() => { + effect( + () => { + this.context.contextVersion(); + if (!this.context.initialized() || this.syncingFromUrl) { + return; + } - const currentUrl = this.resolveCurrentUrl(); - if (!this.isScopeManagedPath(currentUrl)) { - return; - } + const currentUrl = this.resolveCurrentUrl(); + if (!this.isScopeManagedPath(currentUrl)) { + return; + } - const currentTree = this.router.parseUrl(currentUrl); - const nextQuery = { ...currentTree.queryParams }; - const patch = this.context.scopeQueryPatch(); + const currentTree = this.router.parseUrl(currentUrl); + const nextQuery = { ...currentTree.queryParams }; + const patch = this.context.scopeQueryPatch(); - this.applyPatch(nextQuery, patch); - if (this.queryEquals(currentTree.queryParams, nextQuery) || this.syncingToUrl) { - return; - } + this.applyPatch(nextQuery, patch); + if (this.queryEquals(currentTree.queryParams, nextQuery) || this.syncingToUrl) { + return; + } - const nextTree = this.router.parseUrl(currentUrl); - nextTree.queryParams = nextQuery; + const nextTree = this.router.parseUrl(currentUrl); + nextTree.queryParams = nextQuery; - this.syncingToUrl = true; - void this.router.navigateByUrl(nextTree, { - replaceUrl: true, - }).finally(() => { - this.syncingToUrl = false; - }); - }, - { injector: this.injector }, - ); + this.syncingToUrl = true; + void this.router.navigateByUrl(nextTree, { + replaceUrl: true, + }).finally(() => { + this.syncingToUrl = false; + }); + }, + { injector: this.injector }, + ); + }); } private applyScopeFromUrl(): void { diff --git a/src/Web/StellaOps.Web/src/app/core/services/search-assistant-drawer.service.ts b/src/Web/StellaOps.Web/src/app/core/services/search-assistant-drawer.service.ts new file mode 100644 index 000000000..bcb4909e4 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/services/search-assistant-drawer.service.ts @@ -0,0 +1,77 @@ +import { Injectable, computed, signal } from '@angular/core'; + +export interface SearchAssistantOpenRequest { + readonly sequence: number; + readonly initialUserMessage: string | null; + readonly source?: string; +} + +@Injectable({ providedIn: 'root' }) +export class SearchAssistantDrawerService { + private readonly openState = signal(false); + private readonly requestState = signal(null); + private openerElement: HTMLElement | null = null; + private fallbackElement: HTMLElement | null = null; + private sequence = 0; + + readonly isOpen = this.openState.asReadonly(); + readonly request = this.requestState.asReadonly(); + readonly initialUserMessage = computed(() => this.requestState()?.initialUserMessage ?? null); + + open(request: { + initialUserMessage?: string | null; + source?: string; + fallbackFocusTarget?: HTMLElement | null; + } = {}): void { + this.openerElement = this.resolveFocusableActiveElement(); + this.fallbackElement = request.fallbackFocusTarget ?? null; + this.sequence += 1; + this.requestState.set({ + sequence: this.sequence, + initialUserMessage: request.initialUserMessage?.trim() || null, + source: request.source, + }); + this.openState.set(true); + } + + close(options: { restoreFocus?: boolean } = {}): void { + this.openState.set(false); + if (options.restoreFocus === false) { + this.openerElement = null; + this.fallbackElement = null; + return; + } + + const opener = this.openerElement; + const fallback = this.fallbackElement; + this.openerElement = null; + this.fallbackElement = null; + const focusTarget = opener?.isConnected + ? opener + : fallback?.isConnected + ? fallback + : null; + if (!focusTarget) { + return; + } + + setTimeout(() => { + if (focusTarget.isConnected) { + focusTarget.focus(); + } + }, 0); + } + + private resolveFocusableActiveElement(): HTMLElement | null { + if (typeof document === 'undefined') { + return null; + } + + const activeElement = document.activeElement; + if (!(activeElement instanceof HTMLElement) || activeElement === document.body) { + return null; + } + + return activeElement; + } +} diff --git a/src/Web/StellaOps.Web/src/app/core/services/search-chat-context.service.ts b/src/Web/StellaOps.Web/src/app/core/services/search-chat-context.service.ts index 6478dcae3..8a531ba29 100644 --- a/src/Web/StellaOps.Web/src/app/core/services/search-chat-context.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/services/search-chat-context.service.ts @@ -1,14 +1,12 @@ import { Injectable, signal } from '@angular/core'; import { Subject } from 'rxjs'; import type { EntityCard, SynthesisResult, UnifiedSearchDomain } from '../api/unified-search.models'; -import type { SearchExperienceMode } from './search-experience-mode.service'; export interface SearchToChatContext { query: string; entityCards: EntityCard[]; synthesis: SynthesisResult | null; suggestedPrompt?: string; - mode?: SearchExperienceMode; } export interface ChatToSearchContext { @@ -16,7 +14,6 @@ export interface ChatToSearchContext { domain?: UnifiedSearchDomain; entityKey?: string; action?: 'chat_search_for_more' | 'chat_search_related' | 'chat_next_step_search' | 'chat_next_step_policy'; - mode?: SearchExperienceMode; } @Injectable({ providedIn: 'root' }) diff --git a/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat-message.component.ts b/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat-message.component.ts index 3a1d6af7d..be7247bb2 100644 --- a/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat-message.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat-message.component.ts @@ -19,7 +19,6 @@ import { import { ObjectLinkChipComponent } from './object-link-chip.component'; import { ActionButtonComponent } from './action-button.component'; import { SearchChatContextService } from '../../../core/services/search-chat-context.service'; -import { SearchExperienceModeService, type SearchExperienceMode } from '../../../core/services/search-experience-mode.service'; import type { UnifiedSearchDomain } from '../../../core/api/unified-search.models'; interface MessageSegment { @@ -166,7 +165,6 @@ interface NextStepCard {
Suggested next steps - {{ currentModeLabel() }}
@for (card of nextStepCards(); track card.id) { @@ -449,7 +447,6 @@ interface NextStepCard { .next-steps__header { display: flex; align-items: center; - justify-content: space-between; gap: 0.5rem; margin-bottom: 0.75rem; } @@ -460,14 +457,6 @@ interface NextStepCard { color: var(--color-text-primary); } - .next-steps__mode { - font-size: var(--font-size-xs); - color: var(--color-brand-primary, #2563eb); - background: var(--color-brand-primary-10, #eff6ff); - border-radius: 999px; - padding: 0.1875rem 0.5rem; - } - .next-steps__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -574,13 +563,9 @@ export class ChatMessageComponent { private readonly router = inject(Router); private readonly searchChatContext = inject(SearchChatContextService); - private readonly searchExperienceMode = inject(SearchExperienceModeService); readonly showCitations = signal(false); readonly copied = signal(false); - readonly currentMode = this.searchExperienceMode.mode; - readonly currentModeLabel = computed(() => - `${this.searchExperienceMode.definition().labelFallback} mode`); readonly segments = computed(() => this.parseContent(this.turn.content)); readonly nextStepCards = computed(() => this.buildNextStepCards()); @@ -702,7 +687,6 @@ export class ChatMessageComponent { domain, entityKey: firstCitation?.path, action: 'chat_search_for_more', - mode: this.searchExperienceMode.currentMode(), }); this.searchForMore.emit(query); } @@ -715,7 +699,6 @@ export class ChatMessageComponent { domain, entityKey: citation.path, action: 'chat_search_related', - mode: this.searchExperienceMode.currentMode(), }); this.searchForMore.emit(query); } @@ -727,7 +710,6 @@ export class ChatMessageComponent { domain: card.domain, entityKey: card.entityKey, action: card.chatAction, - mode: this.searchExperienceMode.currentMode(), }); this.searchForMore.emit(card.query); return; @@ -788,7 +770,6 @@ export class ChatMessageComponent { return []; } - const mode = this.searchExperienceMode.currentMode(); const primaryCitation = this.pickPrimaryCitation(citations); const baseQuery = primaryCitation ? this.extractSearchQueryFromCitation(primaryCitation.type, primaryCitation.path) @@ -800,12 +781,12 @@ export class ChatMessageComponent { cards.push(evidenceCard); } - const searchCard = this.buildSearchCard(baseQuery, primaryCitation, mode); + const searchCard = this.buildSearchCard(baseQuery, primaryCitation); if (searchCard) { cards.push(searchCard); } - const policyCard = this.buildPolicyCard(baseQuery, primaryCitation, mode); + const policyCard = this.buildPolicyCard(baseQuery, primaryCitation); if (policyCard) { cards.push(policyCard); } @@ -819,16 +800,15 @@ export class ChatMessageComponent { .filter((card, index, allCards) => allCards.findIndex((candidate) => candidate.id === card.id) === index) .sort((left, right) => - this.scoreNextStepCard(right, mode) - this.scoreNextStepCard(left, mode)) + this.scoreNextStepCard(right) - this.scoreNextStepCard(left)) .slice(0, 4); } private buildSearchCard( query: string, citation: EvidenceCitation | null, - mode: SearchExperienceMode, ): NextStepCard | null { - const normalizedQuery = this.buildModeAwareNextSearchQuery(query); + const normalizedQuery = this.buildFollowUpSearchQuery(query); if (!normalizedQuery) { return null; } @@ -837,53 +817,23 @@ export class ChatMessageComponent { ? this.buildEvidenceLabel(citation) : 'Evidence-guided search'; - switch (mode) { - case 'explain': - return { - id: 'search', - title: 'Explain related evidence', - description: 'Search for the strongest supporting evidence and clarifying context.', - actionLabel: 'Search explanation', - evidenceLabel, - actionType: 'search', - query: normalizedQuery, - domain: citation ? this.mapCitationTypeToDomain(citation.type) : undefined, - entityKey: citation?.path, - chatAction: 'chat_next_step_search', - }; - case 'act': - return { - id: 'search', - title: 'Find the next operator step', - description: 'Search for mitigations, blockers, and execution guidance tied to this answer.', - actionLabel: 'Search next step', - evidenceLabel, - actionType: 'search', - query: normalizedQuery, - domain: citation ? this.mapCitationTypeToDomain(citation.type) : undefined, - entityKey: citation?.path, - chatAction: 'chat_next_step_search', - }; - default: - return { - id: 'search', - title: 'Search deeper', - description: 'Pull more related results from the strongest cited entity.', - actionLabel: 'Search deeper', - evidenceLabel, - actionType: 'search', - query: normalizedQuery, - domain: citation ? this.mapCitationTypeToDomain(citation.type) : undefined, - entityKey: citation?.path, - chatAction: 'chat_next_step_search', - }; - } + return { + id: 'search', + title: 'Search related evidence', + description: 'Pull more results around the strongest cited entity and its nearby context.', + actionLabel: 'Search related', + evidenceLabel, + actionType: 'search', + query: normalizedQuery, + domain: citation ? this.mapCitationTypeToDomain(citation.type) : undefined, + entityKey: citation?.path, + chatAction: 'chat_next_step_search', + }; } private buildPolicyCard( query: string, citation: EvidenceCitation | null, - mode: SearchExperienceMode, ): NextStepCard | null { const policyQuery = this.buildPolicyQuery(query); if (!policyQuery) { @@ -894,47 +844,18 @@ export class ChatMessageComponent { ? `${this.getCitationLabel(citation)} policy` : 'Policy pivot'; - switch (mode) { - case 'act': - return { - id: 'policy', - title: 'Check policy blockers', - description: 'Pivot into policy gates, exceptions, and approval blockers connected to this answer.', - actionLabel: 'Search blockers', - evidenceLabel, - actionType: 'search', - query: policyQuery, - domain: 'policy', - entityKey: citation?.path, - chatAction: 'chat_next_step_policy', - }; - case 'find': - return { - id: 'policy', - title: 'Find policy matches', - description: 'Search for the rules and gates most closely related to this evidence.', - actionLabel: 'Search policy', - evidenceLabel, - actionType: 'search', - query: policyQuery, - domain: 'policy', - entityKey: citation?.path, - chatAction: 'chat_next_step_policy', - }; - default: - return { - id: 'policy', - title: 'Compare policy impact', - description: 'Search for the policy meaning and release impact behind this answer.', - actionLabel: 'Search impact', - evidenceLabel, - actionType: 'search', - query: policyQuery, - domain: 'policy', - entityKey: citation?.path, - chatAction: 'chat_next_step_policy', - }; - } + return { + id: 'policy', + title: 'Check policy impact', + description: 'Search for gates, exceptions, and release implications connected to this answer.', + actionLabel: 'Search policy', + evidenceLabel, + actionType: 'search', + query: policyQuery, + domain: 'policy', + entityKey: citation?.path, + chatAction: 'chat_next_step_policy', + }; } private buildEvidenceCard(citation: EvidenceCitation | null): NextStepCard | null { @@ -991,20 +912,17 @@ export class ChatMessageComponent { return citations.find((citation) => citation.verified) ?? citations[0] ?? null; } - private buildModeAwareNextSearchQuery(query: string): string { + private buildFollowUpSearchQuery(query: string): string { const normalized = query.trim(); if (!normalized) { return ''; } - switch (this.searchExperienceMode.currentMode()) { - case 'explain': - return `why ${normalized} matters`; - case 'act': - return `next step for ${normalized}`; - default: - return normalized; + if (/^CVE-\d{4}-\d{4,}$/i.test(normalized)) { + return normalized.toUpperCase(); } + + return normalized; } private buildPolicyQuery(query: string): string { @@ -1013,26 +931,19 @@ export class ChatMessageComponent { return ''; } - switch (this.searchExperienceMode.currentMode()) { - case 'act': - return `policy blockers for ${normalized}`; - case 'explain': - return `policy impact of ${normalized}`; - default: - return `policy rules for ${normalized}`; - } + return `policy impact of ${normalized}`; } - private scoreNextStepCard(card: NextStepCard, mode: SearchExperienceMode): number { + private scoreNextStepCard(card: NextStepCard): number { switch (card.id) { case 'evidence': return 100; case 'search': - return mode === 'find' ? 65 : mode === 'act' ? 58 : 46; + return 68; case 'policy': - return mode === 'act' ? 62 : mode === 'explain' ? 60 : 42; + return 72; case 'context': - return mode === 'explain' ? 52 : 44; + return 55; default: return 0; } diff --git a/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat.component.ts b/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat.component.ts index c91f674e1..4a39bab7a 100644 --- a/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat.component.ts @@ -24,10 +24,6 @@ import { Subject, takeUntil } from 'rxjs'; import { ChatService } from './chat.service'; import { ChatMessageComponent } from './chat-message.component'; import { AmbientContextService } from '../../../core/services/ambient-context.service'; -import { - SearchExperienceModeService, - type SearchExperienceMode, -} from '../../../core/services/search-experience-mode.service'; import { I18nService } from '../../../core/i18n'; import { Conversation, @@ -64,20 +60,6 @@ import { {{ conversation()!.conversationId.substring(0, 8) }} }
-
- @for (mode of experienceModeOptions(); track mode.id) { - - } -
@if (conversation()) { @@ -129,7 +111,7 @@ import {

Ask AdvisoryAI

-

{{ modeEmptyStateDescription() }}

+

{{ emptyStateDescription() }}

@for (suggestion of suggestions(); track suggestion) { - - @if (assistantOpen()) { - - } -
+ `, - styles: [` - .triage-host { - position: relative; - min-height: 100%; - } - - .assistant-fab { - position: fixed; - right: 1.25rem; - bottom: 1.25rem; - z-index: 135; - border: 1px solid #7dd3fc; - background: #f0f9ff; - color: #0369a1; - border-radius: 999px; - padding: 0.5rem 0.9rem; - font-size: 0.75rem; - box-shadow: 0 4px 14px rgba(2, 132, 199, 0.2); - cursor: pointer; - } - - .assistant-fab:hover { - background: #e0f2fe; - border-color: #0284c7; - color: #0c4a6e; - } - - .assistant-drawer { - position: fixed; - top: 5rem; - right: 1rem; - width: min(520px, calc(100vw - 2rem)); - height: min(78vh, 760px); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - background: var(--color-surface-primary); - box-shadow: var(--shadow-dropdown); - z-index: 140; - overflow: hidden; - } - - @media (max-width: 900px) { - .assistant-fab { - right: 0.75rem; - bottom: 0.75rem; - } - - .assistant-drawer { - top: 0; - right: 0; - width: 100vw; - height: 100vh; - border-radius: 0; - } - } - `], + styles: [``], }) export class SecurityTriageChatHostComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly searchChatContext = inject(SearchChatContextService); - private readonly searchExperienceMode = inject(SearchExperienceModeService); - readonly context = inject(PlatformContextStore); + private readonly assistantDrawer = inject(SearchAssistantDrawerService); - @ViewChild('assistantDrawer') private assistantDrawerRef?: ElementRef; - @ViewChild('assistantFab') private assistantFabRef?: ElementRef; - - readonly assistantOpen = signal(false); - readonly assistantInitialMessage = signal(null); + readonly assistantOpen = this.assistantDrawer.isOpen; + readonly assistantInitialMessage = this.assistantDrawer.initialUserMessage; constructor() { this.route.queryParamMap @@ -126,17 +36,14 @@ export class SecurityTriageChatHostComponent { } openAssistantPanel(): void { - const directive = this.searchExperienceMode.definition().assistantDirective; - this.assistantInitialMessage.set( - `${directive} Help me prioritize the current security triage findings and explain the top risk first.`, - ); - this.assistantOpen.set(true); - this.queueDrawerFocus(); + this.assistantDrawer.open({ + initialUserMessage: 'I am on security triage. Help me prioritize the current findings, explain the top risk, and tell me the safest next step.', + source: 'security_triage_page', + }); } closeAssistantPanel(): void { - this.assistantOpen.set(false); - setTimeout(() => this.assistantFabRef?.nativeElement?.focus(), 0); + this.assistantDrawer.close(); } onChatSearchForMore(query: string): void { @@ -145,15 +52,16 @@ export class SecurityTriageChatHostComponent { return; } - this.assistantOpen.set(false); + this.assistantDrawer.close(); } private openAssistantFromSearchIntent(querySeed: string): void { const searchContext = this.searchChatContext.consumeSearchToChat(); const prompt = this.buildAssistantPrompt(searchContext, querySeed); - this.assistantInitialMessage.set(prompt); - this.assistantOpen.set(true); - this.queueDrawerFocus(); + this.assistantDrawer.open({ + initialUserMessage: prompt, + source: 'security_triage_query_param', + }); void this.router.navigate([], { relativeTo: this.route, @@ -168,8 +76,6 @@ export class SecurityTriageChatHostComponent { return searchContext.suggestedPrompt.trim(); } - const mode = searchContext?.mode ?? this.searchExperienceMode.currentMode(); - const directive = this.searchExperienceMode.getDefinition(mode).assistantDirective; const query = searchContext?.query?.trim() || querySeed.trim(); const cards = (searchContext?.entityCards ?? []).slice(0, 5); if (cards.length > 0) { @@ -177,26 +83,13 @@ export class SecurityTriageChatHostComponent { .map((card, index) => `${index + 1}. ${card.title} (${card.domain}${card.severity ? `, ${card.severity}` : ''})`) .join('\n'); - return `I searched for "${query || 'security issue'}" and got:\n${cardSummary}\n${directive}`; + return `I searched for "${query || 'security issue'}" and got:\n${cardSummary}\nExplain what matters, what evidence is strongest, and what I should do next.`; } if (query) { - return `${directive} Focus on "${query}" and guide me to the most relevant next step.`; + return `I am on security triage. Focus on "${query}", explain what matters, and guide me to the most relevant next step.`; } - return `${directive} Help me prioritize the current security triage findings and explain the top risk first.`; - } - - @HostListener('window:keydown.escape') - onEscapePressed(): void { - if (!this.assistantOpen()) { - return; - } - - this.closeAssistantPanel(); - } - - private queueDrawerFocus(): void { - setTimeout(() => this.assistantDrawerRef?.nativeElement?.focus(), 0); + return 'I am on security triage. Help me prioritize the current findings, explain the top risk, and tell me the safest next step.'; } } diff --git a/src/Web/StellaOps.Web/src/app/layout/app-shell/app-shell.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-shell/app-shell.component.ts index 5703e34ae..cc1aa9b53 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-shell/app-shell.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-shell/app-shell.component.ts @@ -7,6 +7,7 @@ import { AppSidebarComponent } from '../app-sidebar'; import { BreadcrumbComponent } from '../breadcrumb/breadcrumb.component'; import { OverlayHostComponent } from '../overlay-host/overlay-host.component'; import { SidebarPreferenceService } from '../app-sidebar/sidebar-preference.service'; +import { SearchAssistantHostComponent } from '../search-assistant-host/search-assistant-host.component'; /** * AppShellComponent - Main application shell with permanent left rail navigation. @@ -27,8 +28,9 @@ import { SidebarPreferenceService } from '../app-sidebar/sidebar-preference.serv AppTopbarComponent, AppSidebarComponent, BreadcrumbComponent, - OverlayHostComponent -], + OverlayHostComponent, + SearchAssistantHostComponent, + ], template: `
@@ -74,6 +76,7 @@ import { SidebarPreferenceService } from '../app-sidebar/sidebar-preference.serv +
`, styles: [` diff --git a/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts b/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts index 34506afd8..559b45d5c 100644 --- a/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts @@ -32,6 +32,7 @@ import { EntityCardComponent } from '../../shared/components/entity-card/entity- import { SynthesisPanelComponent } from '../../shared/components/synthesis-panel/synthesis-panel.component'; import { AmbientContextService } from '../../core/services/ambient-context.service'; import { SearchChatContextService } from '../../core/services/search-chat-context.service'; +import { SearchAssistantDrawerService } from '../../core/services/search-assistant-drawer.service'; import { I18nService } from '../../core/i18n'; import { normalizeSearchActionRoute } from './search-route-matrix'; @@ -1094,6 +1095,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { private readonly searchClient = inject(UnifiedSearchClient); private readonly ambientContext = inject(AmbientContextService); private readonly searchChatContext = inject(SearchChatContextService); + private readonly assistantDrawer = inject(SearchAssistantDrawerService); private readonly i18n = inject(I18nService); private readonly destroy$ = new Subject(); private readonly searchTerms$ = new Subject(); @@ -1121,7 +1123,11 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { readonly recentSearches = signal([]); readonly expandedCardKey = signal(null); - readonly showResults = computed(() => this.isFocused()); + readonly showResults = computed(() => + this.isFocused() + || this.isLoading() + || this.query().trim().length >= 1, + ); readonly diagnosticsMode = computed(() => this.searchResponse()?.diagnostics?.mode ?? 'unknown'); readonly isDegradedMode = computed(() => { const mode = this.diagnosticsMode(); @@ -1658,8 +1664,10 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { suggestedPrompt: askPrompt, }); this.closeResults(); - void this.router.navigate(['/security/triage'], { - queryParams: { openChat: 'true', q: this.query().trim() || card.title }, + this.assistantDrawer.open({ + initialUserMessage: askPrompt, + source: 'global_search_card', + fallbackFocusTarget: this.searchInputRef?.nativeElement ?? null, }); } @@ -1676,7 +1684,11 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { suggestedPrompt: askPrompt, }); this.closeResults(); - void this.router.navigate(['/security/triage'], { queryParams: { openChat: 'true', q: this.query() } }); + this.assistantDrawer.open({ + initialUserMessage: askPrompt, + source: 'global_search_synthesis', + fallbackFocusTarget: this.searchInputRef?.nativeElement ?? null, + }); } onTogglePreview(entityKey: string): void { @@ -2205,8 +2217,10 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { suggestedPrompt, }); this.closeResults(); - void this.router.navigate(['/security/triage'], { - queryParams: { openChat: 'true', q: query || pageLabel }, + this.assistantDrawer.open({ + initialUserMessage: suggestedPrompt, + source: 'global_search_answer', + fallbackFocusTarget: this.searchInputRef?.nativeElement ?? null, }); } @@ -2301,8 +2315,10 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { suggestedPrompt, }); this.closeResults(); - void this.router.navigate(['/security/triage'], { - queryParams: { openChat: 'true', q: query || pageLabel }, + this.assistantDrawer.open({ + initialUserMessage: suggestedPrompt, + source: 'global_search_entry', + fallbackFocusTarget: this.searchInputRef?.nativeElement ?? null, }); } diff --git a/src/Web/StellaOps.Web/src/app/layout/search-assistant-host/search-assistant-host.component.ts b/src/Web/StellaOps.Web/src/app/layout/search-assistant-host/search-assistant-host.component.ts new file mode 100644 index 000000000..435b01e4b --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/layout/search-assistant-host/search-assistant-host.component.ts @@ -0,0 +1,119 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + HostListener, + ViewChild, + effect, + inject, +} from '@angular/core'; + +import { PlatformContextStore } from '../../core/context/platform-context.store'; +import { SearchAssistantDrawerService } from '../../core/services/search-assistant-drawer.service'; +import { ChatComponent } from '../../features/advisory-ai/chat'; + +@Component({ + selector: 'app-search-assistant-host', + standalone: true, + imports: [ChatComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (assistantDrawer.isOpen()) { +
+ +
+ } + `, + styles: [` + .assistant-host__backdrop { + position: fixed; + inset: 0; + z-index: 185; + background: rgba(15, 23, 42, 0.16); + backdrop-filter: blur(2px); + display: flex; + align-items: flex-start; + justify-content: flex-end; + padding: 4.5rem 1rem 1rem; + } + + .assistant-host__drawer { + width: min(520px, calc(100vw - 2rem)); + height: min(78vh, 760px); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + box-shadow: var(--shadow-dropdown); + overflow: hidden; + } + + @media (max-width: 900px) { + .assistant-host__backdrop { + padding: 0; + align-items: stretch; + } + + .assistant-host__drawer { + width: 100vw; + height: 100vh; + border-radius: 0; + } + } + `], +}) +export class SearchAssistantHostComponent { + readonly assistantDrawer = inject(SearchAssistantDrawerService); + readonly context = inject(PlatformContextStore); + + @ViewChild('assistantDrawerElement') private drawerRef?: ElementRef; + + constructor() { + effect(() => { + if (!this.assistantDrawer.isOpen()) { + return; + } + + setTimeout(() => this.drawerRef?.nativeElement?.focus(), 0); + }); + } + + close(): void { + this.assistantDrawer.close(); + } + + onSearchForMore(query: string): void { + if (query.trim()) { + this.assistantDrawer.close({ restoreFocus: false }); + } + } + + onBackdropClick(event: MouseEvent): void { + if (event.target === event.currentTarget) { + this.close(); + } + } + + @HostListener('document:keydown.escape') + onEscape(): void { + if (this.assistantDrawer.isOpen()) { + this.close(); + } + } +} diff --git a/src/Web/StellaOps.Web/src/tests/advisory_ai_chat/chat-message.component.spec.ts b/src/Web/StellaOps.Web/src/tests/advisory_ai_chat/chat-message.component.spec.ts index 29f65390f..a8819ff05 100644 --- a/src/Web/StellaOps.Web/src/tests/advisory_ai_chat/chat-message.component.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/advisory_ai_chat/chat-message.component.spec.ts @@ -4,7 +4,6 @@ import { provideRouter, Router } from '@angular/router'; import { ChatMessageComponent } from '../../app/features/advisory-ai/chat/chat-message.component'; import { ConversationTurn } from '../../app/features/advisory-ai/chat/chat.models'; import { SearchChatContextService } from '../../app/core/services/search-chat-context.service'; -import { SearchExperienceModeService } from '../../app/core/services/search-experience-mode.service'; const assistantTurn: ConversationTurn = { turnId: 'turn-2', @@ -26,7 +25,6 @@ describe('ChatMessageComponent (advisory_ai_chat)', () => { let fixture: ComponentFixture; let component: ChatMessageComponent; let searchChatContext: SearchChatContextService; - let searchExperienceMode: SearchExperienceModeService; let router: Router; beforeEach(async () => { @@ -40,7 +38,6 @@ describe('ChatMessageComponent (advisory_ai_chat)', () => { fixture = TestBed.createComponent(ChatMessageComponent); component = fixture.componentInstance; searchChatContext = TestBed.inject(SearchChatContextService); - searchExperienceMode = TestBed.inject(SearchExperienceModeService); router = TestBed.inject(Router); component.turn = assistantTurn; fixture.detectChanges(); @@ -81,7 +78,6 @@ describe('ChatMessageComponent (advisory_ai_chat)', () => { action: 'chat_search_for_more', domain: 'findings', entityKey: 'api-gateway:grpc.Server', - mode: 'find', })); }); @@ -95,21 +91,18 @@ describe('ChatMessageComponent (advisory_ai_chat)', () => { domain: 'policy', entityKey: 'DENY-CRITICAL-PROD:1', action: 'chat_search_related', - mode: 'find', }); }); - it('renders structured next-step cards for assistant messages with citations', () => { + it('renders structured next-step cards for assistant messages without a mode badge', () => { const cards = fixture.nativeElement.querySelectorAll('.next-step-card'); const modeBadge = fixture.nativeElement.querySelector('.next-steps__mode') as HTMLElement | null; expect(cards.length).toBe(4); - expect(modeBadge?.textContent?.trim()).toBe('Find mode'); + expect(modeBadge).toBeNull(); }); - it('uses the active mode when a next-step search card hands control back to search', () => { - searchExperienceMode.setMode('act'); - fixture.detectChanges(); + it('hands next-step search cards back to search without a mode dependency', () => { const contextSpy = spyOn(searchChatContext, 'setChatToSearch'); const nextSearchCard = component.nextStepCards().find((card) => card.id === 'policy'); @@ -120,7 +113,7 @@ describe('ChatMessageComponent (advisory_ai_chat)', () => { expect(contextSpy).toHaveBeenCalledWith(jasmine.objectContaining({ action: 'chat_next_step_policy', domain: 'policy', - mode: 'act', + query: jasmine.stringMatching(/^policy impact of /i), })); }); diff --git a/src/Web/StellaOps.Web/src/tests/advisory_ai_chat/chat.component.spec.ts b/src/Web/StellaOps.Web/src/tests/advisory_ai_chat/chat.component.spec.ts index 93692d9e9..b5d1baa6b 100644 --- a/src/Web/StellaOps.Web/src/tests/advisory_ai_chat/chat.component.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/advisory_ai_chat/chat.component.spec.ts @@ -3,7 +3,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { of, Subject } from 'rxjs'; import { AmbientContextService } from '../../app/core/services/ambient-context.service'; -import { SearchExperienceModeService } from '../../app/core/services/search-experience-mode.service'; import { I18nService } from '../../app/core/i18n'; import { ChatComponent } from '../../app/features/advisory-ai/chat/chat.component'; import type { Conversation, StreamEvent } from '../../app/features/advisory-ai/chat/chat.models'; @@ -12,7 +11,6 @@ import { ChatService } from '../../app/features/advisory-ai/chat/chat.service'; describe('ChatComponent (advisory_ai_chat)', () => { let fixture: ComponentFixture; let component: ChatComponent; - let searchExperienceMode: SearchExperienceModeService; const conversationState = signal(null); const isLoadingState = signal(false); @@ -83,35 +81,28 @@ describe('ChatComponent (advisory_ai_chat)', () => { fixture = TestBed.createComponent(ChatComponent); component = fixture.componentInstance; - searchExperienceMode = TestBed.inject(SearchExperienceModeService); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); }); - it('renders the mode switcher in the chat header', () => { + it('does not render a mode switcher in the chat header', () => { const buttons = fixture.nativeElement.querySelectorAll('.chat-mode-btn'); - - expect(buttons.length).toBe(3); - expect(buttons[0].textContent.trim()).toBe('Find'); - expect(buttons[1].textContent.trim()).toBe('Explain'); - expect(buttons[2].textContent.trim()).toBe('Act'); + expect(buttons.length).toBe(0); }); - it('updates starter suggestions and placeholder when the mode changes', () => { - const modeButtons = fixture.nativeElement.querySelectorAll('.chat-mode-btn'); - (modeButtons[1] as HTMLButtonElement).click(); + it('uses a generic empty state, starter suggestions, and placeholder', () => { fixture.detectChanges(); const suggestionButtons = fixture.nativeElement.querySelectorAll('.suggestion-btn'); const textarea = fixture.nativeElement.querySelector('.chat-input') as HTMLTextAreaElement | null; const emptyState = fixture.nativeElement.querySelector('.empty-state p') as HTMLElement | null; - expect(searchExperienceMode.currentMode()).toBe('explain'); - expect(emptyState?.textContent).toContain('Understand why it matters'); + expect(emptyState?.textContent).toContain('current page'); expect(suggestionButtons[0].textContent.trim()).toBe( - 'Explain the evidence chain and policy impact behind the top issue.', + 'Summarize what matters here and what I should do next.', ); - expect(textarea?.placeholder).toContain('explain this issue'); + expect(suggestionButtons[1].textContent.trim()).toBe('What can Stella Ops do?'); + expect(textarea?.placeholder).toContain('page, result, or next step'); }); }); diff --git a/src/Web/StellaOps.Web/src/tests/global_search/global-search.component.spec.ts b/src/Web/StellaOps.Web/src/tests/global_search/global-search.component.spec.ts index fbf7c4958..6d495c32c 100644 --- a/src/Web/StellaOps.Web/src/tests/global_search/global-search.component.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/global_search/global-search.component.spec.ts @@ -5,6 +5,7 @@ import { Subject, of } from 'rxjs'; import type { EntityCard } from '../../app/core/api/unified-search.models'; import { UnifiedSearchClient } from '../../app/core/api/unified-search.client'; import { AmbientContextService } from '../../app/core/services/ambient-context.service'; +import { SearchAssistantDrawerService } from '../../app/core/services/search-assistant-drawer.service'; import { SearchChatContextService } from '../../app/core/services/search-chat-context.service'; import { I18nService } from '../../app/core/i18n'; import { GlobalSearchComponent } from '../../app/layout/global-search/global-search.component'; @@ -17,6 +18,7 @@ describe('GlobalSearchComponent', () => { let routerEvents: Subject; let router: { url: string; events: Subject; navigateByUrl: jasmine.Spy; navigate: jasmine.Spy }; let searchChatContext: jasmine.SpyObj; + let assistantDrawer: jasmine.SpyObj; beforeEach(async () => { localStorage.clear(); @@ -150,6 +152,11 @@ describe('GlobalSearchComponent', () => { (searchChatContext as any).chatToSearchRequested$ = of(); searchChatContext.consumeChatToSearch.and.returnValue(null); + assistantDrawer = jasmine.createSpyObj('SearchAssistantDrawerService', [ + 'open', + 'close', + ]) as jasmine.SpyObj; + await TestBed.configureTestingModule({ imports: [GlobalSearchComponent], providers: [ @@ -163,6 +170,7 @@ describe('GlobalSearchComponent', () => { }, }, { provide: SearchChatContextService, useValue: searchChatContext }, + { provide: SearchAssistantDrawerService, useValue: assistantDrawer }, ], }).compileComponents(); @@ -299,7 +307,7 @@ describe('GlobalSearchComponent', () => { })); }); - it('navigates to assistant host with openChat intent from Ask AI card action', () => { + it('opens the shell assistant from Ask AI card action without route navigation', () => { const card = createCard('findings', '/triage/findings/fnd-1'); component.onAskAiFromCard(card); @@ -308,14 +316,11 @@ describe('GlobalSearchComponent', () => { query: 'findings sample', suggestedPrompt: jasmine.stringMatching(/why it matters/i), })); - expect(router.navigate).toHaveBeenCalledWith( - ['/security/triage'], - jasmine.objectContaining({ - queryParams: jasmine.objectContaining({ - openChat: 'true', - }), - }), - ); + expect(assistantDrawer.open).toHaveBeenCalledWith(jasmine.objectContaining({ + initialUserMessage: jasmine.stringMatching(/why it matters/i), + source: 'global_search_card', + })); + expect(router.navigate).not.toHaveBeenCalled(); }); it('normalizes and navigates entity-card actions for all primary domains', () => { @@ -612,7 +617,7 @@ describe('GlobalSearchComponent', () => { expect(searchCall!.args[1]).toBeUndefined(); }); - it('opens AdvisoryAI from the search bar with page and query context', () => { + it('opens AdvisoryAI from the search bar with page and query context without route navigation', () => { component.onFocus(); component.query.set('mismatch'); @@ -626,15 +631,11 @@ describe('GlobalSearchComponent', () => { query: 'mismatch', suggestedPrompt: jasmine.stringMatching(/searched for "mismatch"/i), })); - expect(router.navigate).toHaveBeenCalledWith( - ['/security/triage'], - jasmine.objectContaining({ - queryParams: jasmine.objectContaining({ - openChat: 'true', - q: 'mismatch', - }), - }), - ); + expect(assistantDrawer.open).toHaveBeenCalledWith(jasmine.objectContaining({ + initialUserMessage: jasmine.stringMatching(/searched for "mismatch"/i), + source: 'global_search_entry', + })); + expect(router.navigate).not.toHaveBeenCalled(); }); it('does not render explicit mode or scope controls', async () => { diff --git a/src/Web/StellaOps.Web/src/tests/layout/search-assistant-host.component.spec.ts b/src/Web/StellaOps.Web/src/tests/layout/search-assistant-host.component.spec.ts new file mode 100644 index 000000000..705848f56 --- /dev/null +++ b/src/Web/StellaOps.Web/src/tests/layout/search-assistant-host.component.spec.ts @@ -0,0 +1,121 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { PlatformContextStore } from '../../app/core/context/platform-context.store'; +import { SearchAssistantDrawerService } from '../../app/core/services/search-assistant-drawer.service'; +import { SearchAssistantHostComponent } from '../../app/layout/search-assistant-host/search-assistant-host.component'; + +@Component({ + selector: 'stellaops-chat', + standalone: true, + template: '', +}) +class ChatStubComponent { + @Input() tenantId = 'default'; + @Input() initialUserMessage: string | null = null; + @Output() close = new EventEmitter(); + @Output() searchForMore = new EventEmitter(); +} + +describe('SearchAssistantHostComponent', () => { + let fixture: ComponentFixture; + let component: SearchAssistantHostComponent; + let assistantDrawer: SearchAssistantDrawerService; + let opener: HTMLButtonElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SearchAssistantHostComponent], + providers: [ + SearchAssistantDrawerService, + { + provide: PlatformContextStore, + useValue: { + tenantId: () => 'tenant-host', + }, + }, + ], + }) + .overrideComponent(SearchAssistantHostComponent, { + set: { + imports: [ChatStubComponent], + }, + }) + .compileComponents(); + + assistantDrawer = TestBed.inject(SearchAssistantDrawerService); + fixture = TestBed.createComponent(SearchAssistantHostComponent); + component = fixture.componentInstance; + opener = document.createElement('button'); + opener.type = 'button'; + opener.textContent = 'Open assistant'; + document.body.appendChild(opener); + fixture.detectChanges(); + }); + + afterEach(() => { + opener.remove(); + }); + + it('renders the shell-level assistant drawer with the requested prompt', () => { + assistantDrawer.open({ + initialUserMessage: 'Help me understand this release blocker.', + source: 'test', + }); + fixture.detectChanges(); + + const chat = fixture.debugElement.query(By.directive(ChatStubComponent)).componentInstance as ChatStubComponent; + expect(fixture.nativeElement.querySelector('.assistant-host__drawer')).not.toBeNull(); + expect(chat.tenantId).toBe('tenant-host'); + expect(chat.initialUserMessage).toBe('Help me understand this release blocker.'); + }); + + it('closes the drawer when chat emits searchForMore with a query', () => { + assistantDrawer.open({ + initialUserMessage: 'Help me understand this release blocker.', + source: 'test', + }); + fixture.detectChanges(); + + component.onSearchForMore('release blocker'); + fixture.detectChanges(); + + expect(assistantDrawer.isOpen()).toBeFalse(); + }); + + it('restores focus to the opener when the drawer closes', async () => { + opener.focus(); + assistantDrawer.open({ + initialUserMessage: 'Help me understand this release blocker.', + source: 'test', + }); + fixture.detectChanges(); + + component.close(); + fixture.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(document.activeElement).toBe(opener); + }); + + it('falls back to the provided focus target when the opener no longer exists', async () => { + const fallback = document.createElement('input'); + document.body.appendChild(fallback); + opener.focus(); + assistantDrawer.open({ + initialUserMessage: 'Help me understand this release blocker.', + source: 'test', + fallbackFocusTarget: fallback, + }); + fixture.detectChanges(); + + opener.remove(); + component.close(); + fixture.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(document.activeElement).toBe(fallback); + fallback.remove(); + }); +}); diff --git a/src/Web/StellaOps.Web/src/tests/security/security-triage-chat-host.component.spec.ts b/src/Web/StellaOps.Web/src/tests/security/security-triage-chat-host.component.spec.ts index 15b223372..480e4d67e 100644 --- a/src/Web/StellaOps.Web/src/tests/security/security-triage-chat-host.component.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/security/security-triage-chat-host.component.spec.ts @@ -1,9 +1,9 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Component } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router, convertToParamMap, type ParamMap } from '@angular/router'; import { BehaviorSubject } from 'rxjs'; -import { PlatformContextStore } from '../../app/core/context/platform-context.store'; +import { SearchAssistantDrawerService } from '../../app/core/services/search-assistant-drawer.service'; import { SearchChatContextService } from '../../app/core/services/search-chat-context.service'; import type { EntityCard } from '../../app/core/api/unified-search.models'; import { SecurityTriageChatHostComponent } from '../../app/features/security/security-triage-chat-host.component'; @@ -15,30 +15,23 @@ import { SecurityTriageChatHostComponent } from '../../app/features/security/sec }) class SecurityFindingsPageStubComponent {} -@Component({ - selector: 'stellaops-chat', - standalone: true, - template: '', -}) -class ChatStubComponent { - @Input() tenantId = 'default'; - @Input() initialUserMessage: string | null = null; - @Output() close = new EventEmitter(); - @Output() searchForMore = new EventEmitter(); -} - describe('SecurityTriageChatHostComponent', () => { let fixture: ComponentFixture; let component: SecurityTriageChatHostComponent; let queryParamMap$: BehaviorSubject; let router: { navigate: jasmine.Spy }; let searchChatContext: SearchChatContextService; + let assistantDrawer: jasmine.SpyObj; beforeEach(async () => { queryParamMap$ = new BehaviorSubject(convertToParamMap({})); router = { navigate: jasmine.createSpy('navigate').and.returnValue(Promise.resolve(true)), }; + assistantDrawer = jasmine.createSpyObj('SearchAssistantDrawerService', ['open', 'close'], { + isOpen: () => false, + initialUserMessage: () => null, + }) as jasmine.SpyObj; await TestBed.configureTestingModule({ imports: [SecurityTriageChatHostComponent], @@ -51,17 +44,12 @@ describe('SecurityTriageChatHostComponent', () => { }, }, { provide: Router, useValue: router }, - { - provide: PlatformContextStore, - useValue: { - tenantId: () => 'test-tenant', - }, - }, + { provide: SearchAssistantDrawerService, useValue: assistantDrawer }, ], }) .overrideComponent(SecurityTriageChatHostComponent, { set: { - imports: [SecurityFindingsPageStubComponent, ChatStubComponent], + imports: [SecurityFindingsPageStubComponent], }, }) .compileComponents(); @@ -98,19 +86,18 @@ describe('SecurityTriageChatHostComponent', () => { })); fixture.detectChanges(); - expect(component.assistantOpen()).toBeTrue(); - expect(component.assistantInitialMessage()).toContain('CVE-2024-21626'); + expect(assistantDrawer.open).toHaveBeenCalledWith(jasmine.objectContaining({ + initialUserMessage: jasmine.stringMatching(/CVE-2024-21626/), + source: 'security_triage_query_param', + })); expect(router.navigate).toHaveBeenCalledWith([], jasmine.objectContaining({ queryParams: jasmine.objectContaining({ openChat: null }), })); }); - it('closes assistant on chat search return signal when query is provided', () => { - component.openAssistantPanel(); - expect(component.assistantOpen()).toBeTrue(); - + it('closes the shell assistant on chat search return signal when query is provided', () => { component.onChatSearchForMore('CVE-2024-21626'); - expect(component.assistantOpen()).toBeFalse(); + expect(assistantDrawer.close).toHaveBeenCalled(); }); }); diff --git a/src/Web/StellaOps.Web/tests/e2e/assistant-entry-search-reliability.spec.ts b/src/Web/StellaOps.Web/tests/e2e/assistant-entry-search-reliability.spec.ts index b1a9dc2cb..c34a9ccdb 100644 --- a/src/Web/StellaOps.Web/tests/e2e/assistant-entry-search-reliability.spec.ts +++ b/src/Web/StellaOps.Web/tests/e2e/assistant-entry-search-reliability.spec.ts @@ -163,7 +163,7 @@ test.describe('Assistant entry and search reliability', () => { await page.keyboard.press('Escape'); await expect(assistantDrawer).toBeHidden(); - await expect(page.locator('.assistant-fab')).toBeFocused(); + await expect(searchInput).toBeFocused(); }); }); diff --git a/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.live.e2e.spec.ts b/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.live.e2e.spec.ts index ffd2af2b1..462b58bac 100644 --- a/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.live.e2e.spec.ts +++ b/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.live.e2e.spec.ts @@ -108,9 +108,11 @@ const mockChecks = { test.describe('Unified Search - Live contextual suggestions', () => { test.describe.configure({ mode: 'serial' }); + test.setTimeout(120_000); test.skip(!liveSearchBaseUrl, 'Set LIVE_ADVISORYAI_SEARCH_BASE_URL to a running local AdvisoryAI service.'); - test.beforeAll(async () => { + test.beforeAll(async ({}, testInfo) => { + testInfo.setTimeout(120_000); await ensureLiveServiceHealthy(liveSearchBaseUrl); await rebuildLiveIndexes(liveSearchBaseUrl); await assertLiveSuggestionCoverage(liveSearchBaseUrl, liveSuggestionSeedQueries);