Unify search-first assistant drawer
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [`
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user