Unify search-first assistant drawer

This commit is contained in:
master
2026-03-07 19:29:56 +02:00
parent 3d036a144e
commit 536d3fe6bd
17 changed files with 510 additions and 460 deletions

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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<SearchAssistantOpenRequest | null>(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;
}
}

View File

@@ -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' })

View File

@@ -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 {
<section class="next-steps" aria-label="Suggested next steps">
<div class="next-steps__header">
<span class="next-steps__title">Suggested next steps</span>
<span class="next-steps__mode">{{ currentModeLabel() }}</span>
</div>
<div class="next-steps__grid">
@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;
}

View File

@@ -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 {
<span class="conversation-id">{{ conversation()!.conversationId.substring(0, 8) }}</span>
}
</div>
<div class="chat-mode-switcher">
@for (mode of experienceModeOptions(); track mode.id) {
<button
type="button"
class="chat-mode-btn"
data-role="chat-mode"
[class.chat-mode-btn--active]="experienceMode() === mode.id"
[title]="mode.description"
(click)="setMode(mode.id)"
>
{{ mode.label }}
</button>
}
</div>
</div>
<div class="header-right">
@if (conversation()) {
@@ -129,7 +111,7 @@ import {
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<h3>Ask AdvisoryAI</h3>
<p>{{ modeEmptyStateDescription() }}</p>
<p>{{ emptyStateDescription() }}</p>
<div class="suggestions">
@for (suggestion of suggestions(); track suggestion) {
<button
@@ -278,38 +260,6 @@ import {
border-radius: var(--radius-sm);
}
.chat-mode-switcher {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.1875rem;
border: 1px solid var(--color-border-primary);
border-radius: 999px;
background: var(--color-surface-tertiary);
}
.chat-mode-btn {
border: none;
background: transparent;
color: var(--color-text-secondary);
border-radius: 999px;
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
cursor: pointer;
transition: background-color 0.12s, color 0.12s;
}
.chat-mode-btn:hover {
color: var(--color-text-primary);
background: var(--color-nav-hover);
}
.chat-mode-btn--active {
background: var(--color-brand-primary-10, #eff6ff);
color: var(--color-brand-primary, #1d4ed8);
font-weight: var(--font-weight-semibold);
}
.header-right {
display: flex;
align-items: center;
@@ -613,7 +563,6 @@ export class ChatComponent implements OnInit, OnDestroy {
private readonly chatService = inject(ChatService);
private readonly ambientContext = inject(AmbientContextService);
private readonly searchExperienceMode = inject(SearchExperienceModeService);
private readonly i18n = inject(I18nService);
private readonly destroy$ = new Subject<void>();
private pendingInitialMessage: string | null = null;
@@ -637,33 +586,24 @@ export class ChatComponent implements OnInit, OnDestroy {
return this.i18n.tryT('ui.chat.input.waiting') ?? 'Waiting for response...';
}
switch (this.experienceMode()) {
case 'explain':
return this.i18n.tryT('ui.chat.input.placeholder.explain') ?? 'Ask AdvisoryAI to explain this issue...';
case 'act':
return this.i18n.tryT('ui.chat.input.placeholder.act') ?? 'Ask AdvisoryAI what to do next...';
default:
return this.i18n.tryT('ui.chat.input.placeholder') ?? 'Ask AdvisoryAI about this finding...';
}
return this.i18n.tryT('ui.chat.input.placeholder')
?? 'Ask about this page, result, or next step...';
});
readonly experienceMode = this.searchExperienceMode.mode;
readonly experienceModeOptions = computed(() =>
this.searchExperienceMode.definitions.map((mode) => ({
id: mode.id,
label: this.i18n.tryT(mode.labelKey) ?? mode.labelFallback,
description: this.i18n.tryT(mode.descriptionKey) ?? mode.descriptionFallback,
})));
readonly modeEmptyStateDescription = computed(() =>
this.i18n.tryT(this.searchExperienceMode.definition().descriptionKey)
?? this.searchExperienceMode.definition().descriptionFallback);
readonly emptyStateDescription = computed(() =>
this.i18n.tryT('ui.chat.empty_state.description')
?? 'Ask about the current page, the strongest evidence, or the safest next step.');
readonly suggestions = computed(() => {
const starter = this.searchExperienceMode.definition().chatStarter;
const starter = this.i18n.tryT('ui.chat.suggestion.default.summary')
?? 'Summarize what matters here and what I should do next.';
const ambient = this.ambientContext
.getChatSuggestions()
.map((suggestion) => this.i18n.tryT(suggestion.key) ?? suggestion.fallback);
return [starter, ...ambient.filter((suggestion) => suggestion !== starter)].slice(0, 5);
return Array.from(new Set([
starter,
...ambient.filter((suggestion) => suggestion !== starter),
])).slice(0, 5);
});
constructor() {
@@ -724,10 +664,6 @@ export class ChatComponent implements OnInit, OnDestroy {
this.sendMessage();
}
setMode(mode: SearchExperienceMode): void {
this.searchExperienceMode.setMode(mode);
}
handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();

View File

@@ -1,119 +1,29 @@
import { ChangeDetectionStrategy, Component, ElementRef, HostListener, ViewChild, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { SearchChatContextService, type SearchToChatContext } from '../../core/services/search-chat-context.service';
import { SearchExperienceModeService } from '../../core/services/search-experience-mode.service';
import { ChatComponent } from '../advisory-ai/chat';
import { SearchAssistantDrawerService } from '../../core/services/search-assistant-drawer.service';
import { SecurityFindingsPageComponent } from './security-findings-page.component';
@Component({
selector: 'app-security-triage-chat-host',
standalone: true,
imports: [SecurityFindingsPageComponent, ChatComponent],
imports: [SecurityFindingsPageComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="triage-host">
<app-security-findings-page />
<button
#assistantFab
type="button"
class="assistant-fab"
(click)="openAssistantPanel()"
aria-label="Open AdvisoryAI assistant"
>
Ask AdvisoryAI
</button>
@if (assistantOpen()) {
<section
#assistantDrawer
class="assistant-drawer"
role="dialog"
aria-label="AdvisoryAI assistant"
tabindex="-1"
>
<stellaops-chat
[tenantId]="context.tenantId() ?? 'default'"
[initialUserMessage]="assistantInitialMessage()"
(close)="closeAssistantPanel()"
(searchForMore)="onChatSearchForMore($event)"
/>
</section>
}
</section>
<app-security-findings-page />
`,
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<HTMLElement>;
@ViewChild('assistantFab') private assistantFabRef?: ElementRef<HTMLButtonElement>;
readonly assistantOpen = signal(false);
readonly assistantInitialMessage = signal<string | null>(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.';
}
}

View File

@@ -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: `
<div class="shell" [class.shell--mobile-open]="mobileMenuOpen()" [class.shell--collapsed]="sidebarPrefs.sidebarCollapsed()">
<!-- Skip link for accessibility -->
@@ -74,6 +76,7 @@ import { SidebarPreferenceService } from '../app-sidebar/sidebar-preference.serv
<!-- Overlay host for drawers/modals -->
<app-overlay-host></app-overlay-host>
<app-search-assistant-host></app-search-assistant-host>
</div>
`,
styles: [`

View File

@@ -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<void>();
private readonly searchTerms$ = new Subject<string>();
@@ -1121,7 +1123,11 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
readonly recentSearches = signal<string[]>([]);
readonly expandedCardKey = signal<string | null>(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,
});
}

View File

@@ -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()) {
<div
class="assistant-host__backdrop"
(click)="onBackdropClick($event)"
>
<section
#assistantDrawerElement
class="assistant-host__drawer assistant-drawer"
role="dialog"
aria-modal="true"
aria-label="AdvisoryAI assistant"
tabindex="-1"
>
<stellaops-chat
[tenantId]="context.tenantId() ?? 'default'"
[initialUserMessage]="assistantDrawer.initialUserMessage()"
(close)="close()"
(searchForMore)="onSearchForMore($event)"
/>
</section>
</div>
}
`,
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<HTMLElement>;
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();
}
}
}

View File

@@ -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<ChatMessageComponent>;
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),
}));
});

View File

@@ -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<ChatComponent>;
let component: ChatComponent;
let searchExperienceMode: SearchExperienceModeService;
const conversationState = signal<Conversation | null>(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');
});
});

View File

@@ -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<unknown>;
let router: { url: string; events: Subject<unknown>; navigateByUrl: jasmine.Spy; navigate: jasmine.Spy };
let searchChatContext: jasmine.SpyObj<SearchChatContextService>;
let assistantDrawer: jasmine.SpyObj<SearchAssistantDrawerService>;
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<SearchAssistantDrawerService>;
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 () => {

View File

@@ -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<void>();
@Output() searchForMore = new EventEmitter<string>();
}
describe('SearchAssistantHostComponent', () => {
let fixture: ComponentFixture<SearchAssistantHostComponent>;
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();
});
});

View File

@@ -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<void>();
@Output() searchForMore = new EventEmitter<string>();
}
describe('SecurityTriageChatHostComponent', () => {
let fixture: ComponentFixture<SecurityTriageChatHostComponent>;
let component: SecurityTriageChatHostComponent;
let queryParamMap$: BehaviorSubject<ParamMap>;
let router: { navigate: jasmine.Spy };
let searchChatContext: SearchChatContextService;
let assistantDrawer: jasmine.SpyObj<SearchAssistantDrawerService>;
beforeEach(async () => {
queryParamMap$ = new BehaviorSubject<ParamMap>(convertToParamMap({}));
router = {
navigate: jasmine.createSpy('navigate').and.returnValue(Promise.resolve(true)),
};
assistantDrawer = jasmine.createSpyObj('SearchAssistantDrawerService', ['open', 'close'], {
isOpen: () => false,
initialUserMessage: () => null,
}) as jasmine.SpyObj<SearchAssistantDrawerService>;
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();
});
});

View File

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

View File

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