diff --git a/docs/implplan/SPRINT_20260307_018_FE_search_primary_entry_consolidation.md b/docs/implplan/SPRINT_20260307_018_FE_search_primary_entry_consolidation.md
index b7b0c764a..d7cd9b3b2 100644
--- a/docs/implplan/SPRINT_20260307_018_FE_search_primary_entry_consolidation.md
+++ b/docs/implplan/SPRINT_20260307_018_FE_search_primary_entry_consolidation.md
@@ -19,7 +19,7 @@
## Delivery Tracker
### FE-ZL-001 - Search-first top-bar entry with secondary chat launcher
-Status: TODO
+Status: DONE
Dependency: none
Owners: Developer (FE)
Task description:
@@ -27,12 +27,12 @@ Task description:
- Opening chat must inherit page context and current query when present.
Completion criteria:
-- [ ] Search remains the primary focus target in the header.
-- [ ] AdvisoryAI launches from a secondary icon/button beside search.
-- [ ] Existing chat handoff still works from result cards and answer panels.
+- [x] Search remains the primary focus target in the header.
+- [x] AdvisoryAI launches from a secondary icon/button beside search.
+- [x] Existing chat handoff still works from result cards and answer panels.
### FE-ZL-002 - Remove explicit mode/scope/recovery controls
-Status: TODO
+Status: DONE
Dependency: FE-ZL-001
Owners: Developer (FE)
Task description:
@@ -40,13 +40,13 @@ Task description:
- Move `Did you mean` immediately under the input.
Completion criteria:
-- [ ] No explicit mode control remains in the search panel.
-- [ ] No explicit scope toggle remains in the search panel.
-- [ ] No recovery panel remains in empty-result states.
-- [ ] `Did you mean` renders directly under the input when present.
+- [x] No explicit mode control remains in the search panel.
+- [x] No explicit scope toggle remains in the search panel.
+- [x] No recovery panel remains in empty-result states.
+- [x] `Did you mean` renders directly under the input when present.
### FE-ZL-003 - History cleanup and low-emphasis clear action
-Status: TODO
+Status: DONE
Dependency: FE-ZL-002
Owners: Developer (FE)
Task description:
@@ -54,32 +54,34 @@ Task description:
- Replace the current clear-history button with a discrete icon action.
Completion criteria:
-- [ ] Recent history excludes searches with zero results.
-- [ ] Clear-history affordance is icon-based and visually low emphasis.
-- [ ] Search history tests cover the new behavior.
+- [x] Recent history excludes searches with zero results.
+- [x] Clear-history affordance is icon-based and visually low emphasis.
+- [x] Search history tests cover the new behavior.
### FE-ZL-004 - Focused FE verification
-Status: TODO
+Status: DONE
Dependency: FE-ZL-003
Owners: Test Automation
Task description:
- Add or update Angular and Playwright tests for the consolidated UI model.
Completion criteria:
-- [ ] Unit tests cover removed controls and new placement rules.
-- [ ] Playwright covers the new top-bar, history, and `Did you mean` behavior.
-- [ ] Tests do not rely on deprecated `mode` or `scope` UI controls.
+- [x] Unit tests cover removed controls and new placement rules.
+- [x] Playwright covers the new top-bar, history, and `Did you mean` behavior.
+- [x] Tests do not rely on deprecated `mode` or `scope` UI controls.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-07 | Sprint created from the zero-learning search strategy. | Project Manager |
+| 2026-03-07 | Implemented the search-first shell: added the secondary AdvisoryAI launcher, removed explicit mode/scope/recovery controls, moved `Did you mean` under the input, migrated recent history to success-only storage, and updated focused Angular plus Playwright coverage. Commands: `npm test -- --include src/tests/global_search/global-search.component.spec.ts`; `npx playwright test tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts tests/e2e/unified-search-self-serve-answer-panel.e2e.spec.ts tests/e2e/unified-search-experience-quality.e2e.spec.ts --config playwright.config.ts`. Results: `22/22` unit tests passed and `11/11` Playwright tests passed. | Developer / Test Automation |
## Decisions & Risks
- Decision: the user should not be asked to choose a search mode before entering a query.
- Decision: page scope becomes implicit UX, not an explicit control.
- Risk: removing explicit controls may expose gaps in backend ranking that modes were previously masking.
- Mitigation: phase 2 adds implicit-scope weighting and answer blending on the backend.
+- Verification note: focused Playwright runs still log an unrelated Angular `NG0602` console error from `PlatformContextUrlSyncService.initialize` plus several refused background requests. The targeted search flows remained green, but that runtime issue should be handled separately by the stabilization workstream.
## Next Checkpoints
- 2026-03-08: Implement FE-ZL-001 through FE-ZL-003.
diff --git a/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts b/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts
index d41d931c6..912930635 100644
--- a/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts
+++ b/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts
@@ -23,8 +23,6 @@ import { UnifiedSearchClient } from '../../core/api/unified-search.client';
import type {
EntityCard,
EntityCardAction,
- SearchRefinement,
- SynthesisResult,
UnifiedSearchResponse,
UnifiedSearchDomain,
} from '../../core/api/unified-search.models';
@@ -33,25 +31,18 @@ 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 {
- SearchExperienceModeService,
- type SearchExperienceMode,
-} from '../../core/services/search-experience-mode.service';
import { I18nService } from '../../core/i18n';
import { normalizeSearchActionRoute } from './search-route-matrix';
type SearchDomainFilter = 'all' | UnifiedSearchDomain;
-type SearchScopeMode = 'page' | 'global';
type SearchSuggestionView = {
query: string;
reason: string;
kind: 'page' | 'recent' | 'strategy';
- preferredModes?: readonly SearchExperienceMode[];
};
type SearchQuestionView = {
query: string;
kind: 'page' | 'clarify' | 'recent';
- preferredModes?: readonly SearchExperienceMode[];
};
type SearchContextPanelView = {
title: string;
@@ -62,11 +53,6 @@ type SearchContextPanelView = {
value: string;
}>;
};
-type RescueActionView = {
- id: 'scope' | 'related' | 'reformulate' | 'page-context';
- label: string;
- description: string;
-};
type SearchAnswerView = {
status: 'grounded' | 'clarify' | 'insufficient';
eyebrow: string;
@@ -108,6 +94,19 @@ type SearchAnswerView = {
aria-autocomplete="list"
/>
+
+
+
+
+
+
{{ shortcutLabel }}
@@ -120,38 +119,20 @@ type SearchAnswerView = {
}
-
-
-
{{ t('ui.search.mode.label', 'Mode') }}
-
{{ experienceModeDescription() }}
+ @if (query().trim().length >= 1 && searchResponse()?.suggestions?.length) {
+
+ {{ t('ui.search.did_you_mean_label', 'Did you mean:') }}
+ @for (suggestion of searchResponse()!.suggestions!; track suggestion.text) {
+
+ {{ suggestion.text }}
+
+ }
-
-
- @for (mode of experienceModeOptions(); track mode.id) {
-
- {{ mode.label }}
-
- }
-
-
- {{ t('ui.search.scope.label', 'Scope') }}: {{ searchScopeLabel() }}
-
-
-
+ }
@if (searchAnswer(); as answer) {
{{ t('ui.search.loading', 'Searching...') }}
} @else if (query().trim().length >= 1 && cards().length === 0) {
{{ t('ui.search.no_results', 'No results found') }}
- @if (searchResponse()?.suggestions?.length) {
-
- {{ t('ui.search.did_you_mean_label', 'Did you mean:') }}
- @for (suggestion of searchResponse()!.suggestions!; track suggestion.text) {
-
- {{ suggestion.text }}
-
- }
-
- }
- @if (refinements().length > 0) {
-
- {{ t('ui.search.try_also_label', 'Try also:') }}
- @for (r of refinements(); track r.text) {
- {{ r.text }}
- }
-
- }
-
-
{{ t('ui.search.rescue.label', 'Recover this search') }}
-
- @for (action of rescueActions(); track action.id) {
-
- {{ action.label }}
- {{ action.description }}
-
- }
-
-
} @else if (query().trim().length >= 1) {
@for (filter of availableDomainFilters(); track filter) {
@@ -285,33 +224,6 @@ type SearchAnswerView = {
}
- @if (searchResponse()?.suggestions?.length) {
-
- {{ t('ui.search.did_you_mean_label', 'Did you mean:') }}
- @for (suggestion of searchResponse()!.suggestions!; track suggestion.text) {
-
- {{ suggestion.text }}
-
- }
-
- }
- @if (refinements().length > 0) {
-
- {{ t('ui.search.try_also_label', 'Try also:') }}
- @for (r of refinements(); track r.text) {
- {{ r.text }}
- }
-
- }
-
@for (card of filteredCards(); track card.entityKey; let i = $index) {
@for (recent of recentSearches(); track recent; let i = $index) {
();
private readonly searchTerms$ = new Subject();
- private readonly recentSearchStorageKey = 'stella-recent-searches';
+ private readonly recentSearchStorageKey = 'stella-successful-searches-v2';
+ private readonly legacyRecentSearchStorageKey = 'stella-recent-searches';
private wasDegradedMode = false;
private escapeCount = 0;
private placeholderRotationHandle: ReturnType | null = null;
@@ -1292,7 +1073,6 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
readonly searchResponse = signal(null);
readonly recentSearches = signal([]);
readonly activeDomainFilter = signal('all');
- readonly searchScope = signal('page');
readonly expandedCardKey = signal(null);
readonly pendingDomainFilter = signal(null);
@@ -1318,24 +1098,6 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
return this.i18n.tryT('ui.search.degraded.results') ??
'Showing legacy fallback results. Coverage and ranking may differ until unified search recovers.';
});
- readonly experienceMode = this.searchExperienceMode.mode;
- readonly experienceModeDefinition = this.searchExperienceMode.definition;
- readonly experienceModeDescription = computed(() =>
- this.t(
- this.experienceModeDefinition().descriptionKey,
- this.experienceModeDefinition().descriptionFallback,
- ));
- readonly experienceModeOptions = computed(() =>
- this.searchExperienceMode.definitions.map((mode) => ({
- id: mode.id,
- label: this.t(mode.labelKey, mode.labelFallback),
- description: this.t(mode.descriptionKey, mode.descriptionFallback),
- })));
- readonly searchScopeLabel = computed(() =>
- this.searchScope() === 'page'
- ? this.t('ui.search.scope.page', 'This page')
- : this.t('ui.search.scope.global', 'All domains'),
- );
private readonly domainGuideCatalog = [
{
@@ -1447,39 +1209,30 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
});
readonly contextualSuggestions = computed(() => {
- const mode = this.experienceMode();
return [...this.ambientContext.getSearchSuggestions()]
- .sort((left, right) =>
- this.scoreSuggestionForMode(right, mode) - this.scoreSuggestionForMode(left, mode))
+ .sort((left, right) => this.scoreSuggestion(right) - this.scoreSuggestion(left))
.map((suggestion) => ({
query: this.i18n.tryT(suggestion.key) ?? suggestion.fallback,
reason: this.resolveSuggestionReason(suggestion),
kind: suggestion.kind ?? 'page',
- preferredModes: suggestion.preferredModes,
}));
});
readonly commonQuestions = computed(() => {
- const mode = this.experienceMode();
- return [...this.ambientContext.getCommonQuestions(mode)]
- .sort((left, right) =>
- this.scoreQuestionForMode(right, mode) - this.scoreQuestionForMode(left, mode))
+ return [...this.ambientContext.getCommonQuestions('find')]
+ .sort((left, right) => this.scoreQuestion(right) - this.scoreQuestion(left))
.map((question) => ({
query: this.i18n.tryT(question.key) ?? question.fallback,
kind: question.kind ?? 'page',
- preferredModes: question.preferredModes,
}));
});
readonly clarifyingQuestions = computed(() => {
- const mode = this.experienceMode();
- return [...this.ambientContext.getClarifyingQuestions(mode)]
- .sort((left, right) =>
- this.scoreQuestionForMode(right, mode) - this.scoreQuestionForMode(left, mode))
+ return [...this.ambientContext.getClarifyingQuestions('find')]
+ .sort((left, right) => this.scoreQuestion(right) - this.scoreQuestion(left))
.map((question) => ({
query: this.i18n.tryT(question.key) ?? question.fallback,
kind: question.kind ?? 'clarify',
- preferredModes: question.preferredModes,
}));
});
@@ -1494,10 +1247,8 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
return null;
}
- const mode = this.experienceMode();
const pageLabel = this.searchContextPanel()?.title ?? this.t('ui.search.answer.context.default', 'Current page');
- const modeLabel = this.experienceModeOptions().find((option) => option.id === mode)?.label ?? mode;
- const eyebrow = `${pageLabel} | ${modeLabel}`;
+ const eyebrow = pageLabel;
const nextSearches = this.contextualSuggestions()
.filter((suggestion) => suggestion.query.trim().toLowerCase() !== query.toLowerCase())
.slice(0, 2);
@@ -1507,7 +1258,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
return {
status: 'grounded',
eyebrow,
- title: this.answerTitleForMode(mode),
+ title: this.t('ui.search.answer.title.find', 'What we found'),
summary: this.buildGroundedAnswerSummary(response),
evidence: this.buildGroundedEvidenceLabel(response),
citations: this.buildAnswerCitations(response),
@@ -1525,13 +1276,12 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
title: this.t('ui.search.answer.title.clarify', 'Tighten the question'),
summary: this.t(
'ui.search.answer.summary.clarify',
- 'I could not form a grounded answer for "{query}" in {page}. Narrow the entity, time window, or scope.',
+ 'I could not form a grounded answer for "{query}" in {page}. Narrow the entity, symptom, or target.',
{ query, page: pageLabel },
),
evidence: this.t(
'ui.search.answer.evidence.clarify',
- 'No direct grounded answer was found in {scope}.',
- { scope: this.searchScopeLabel() },
+ 'No direct grounded answer was found from the current page context.',
),
citations: [],
questionLabel: this.t('ui.search.answer.questions.clarify', 'Clarify with one of these'),
@@ -1551,7 +1301,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
),
evidence: this.t(
'ui.search.answer.evidence.insufficient',
- 'Use a follow-up question, broaden the scope, or ask AdvisoryAI to help frame the next search.',
+ 'Try a clearer target or open chat to deepen the search with page context.',
),
citations: [],
questionLabel: this.t('ui.search.answer.questions.retry', 'Try one of these questions'),
@@ -1560,60 +1310,6 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
};
});
- readonly rescueActions = computed(() => {
- const query = this.query().trim();
- const pageLabel = this.searchContextPanel()?.title ?? 'current page';
- const alternateQuery = this.buildModeAwareAlternativeQuery();
- const actions: RescueActionView[] = [
- {
- id: 'scope',
- label: this.searchScope() === 'page'
- ? this.t('ui.search.rescue.scope_global', 'Broaden to all domains')
- : this.t('ui.search.rescue.scope_page', 'Return to page scope'),
- description: this.searchScope() === 'page'
- ? this.t('ui.search.rescue.scope_global.description', 'Retry the same query without the current page filter.')
- : this.t('ui.search.rescue.scope_page.description', 'Retry the same query with the current page as the focus.'),
- },
- {
- id: 'related',
- label: this.t('ui.search.rescue.related', 'Search a related angle'),
- description: alternateQuery
- ? this.t('ui.search.rescue.related.description', 'Pivot to a related query chosen for the current mode.')
- : this.t('ui.search.rescue.related.none', 'No related pivot is available yet for this page.'),
- },
- {
- id: 'reformulate',
- label: this.t('ui.search.rescue.reformulate', 'Ask AdvisoryAI to reformulate'),
- description: this.t(
- 'ui.search.rescue.reformulate.description',
- 'Open AdvisoryAI with the current query, page context, and active mode.',
- ),
- },
- {
- id: 'page-context',
- label: this.t('ui.search.rescue.page_context', 'Retry with page context'),
- description: this.t(
- 'ui.search.rescue.page_context.description',
- 'Blend the current query with the active page context before retrying.',
- ),
- },
- ];
-
- if (!query) {
- return actions.filter((action) => action.id !== 'page-context');
- }
-
- if (!alternateQuery) {
- return actions.filter((action) => action.id !== 'related');
- }
-
- if (!pageLabel.trim()) {
- return actions.filter((action) => action.id !== 'page-context');
- }
-
- return actions;
- });
-
readonly inputPlaceholder = computed(() => {
const suggestions = this.contextualSuggestions();
if (suggestions.length === 0) {
@@ -1628,8 +1324,6 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
readonly cards = computed(() => this.searchResponse()?.cards ?? []);
readonly synthesis = computed(() => this.searchResponse()?.synthesis ?? null);
- readonly refinements = computed(() => this.searchResponse()?.refinements ?? []);
-
readonly filteredCards = computed(() => {
const filter = this.activeDomainFilter();
if (filter === 'all') {
@@ -1686,11 +1380,8 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
}
this.isLoading.set(true);
- const contextFilter = this.searchScope() === 'page'
- ? this.ambientContext.buildContextFilter()
- : undefined;
const ambient = this.buildAmbientSnapshot();
- return this.searchClient.search(term, contextFilter, 10, ambient).pipe(
+ return this.searchClient.search(term, undefined, 10, ambient).pipe(
catchError(() =>
of({
query: term,
@@ -1730,6 +1421,9 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
this.pendingDomainFilter.set(null);
this.expandedCardKey.set(null);
this.isLoading.set(false);
+ if (this.hasSearchEvidence(response)) {
+ this.saveRecentSearch(response.query);
+ }
// Sprint 106 / G6: Emit search analytics events
this.emitSearchAnalytics(response);
@@ -1893,7 +1587,6 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
entityCards: [card],
synthesis: this.synthesis(),
suggestedPrompt: askPrompt,
- mode: this.experienceMode(),
});
this.closeResults();
void this.router.navigate(['/security/triage'], {
@@ -1912,7 +1605,6 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
entityCards: this.filteredCards(),
synthesis: this.synthesis(),
suggestedPrompt: askPrompt,
- mode: this.experienceMode(),
});
this.closeResults();
void this.router.navigate(['/security/triage'], { queryParams: { openChat: 'true', q: this.query() } });
@@ -2006,22 +1698,10 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
queryHint: text,
});
this.query.set(text);
- this.saveRecentSearch(text);
this.searchTerms$.next(text.trim());
this.keepSearchSurfaceOpen();
}
- applyRefinement(refinement: SearchRefinement): void {
- this.recordAmbientAction('search_refinement', {
- source: 'global_search_refinement',
- queryHint: refinement.text,
- });
- this.query.set(refinement.text);
- this.saveRecentSearch(refinement.text);
- this.searchTerms$.next(refinement.text.trim());
- this.keepSearchSurfaceOpen();
- }
-
navigateQuickAction(route: string): void {
this.recordAmbientAction('search_quick_action', {
source: 'global_search_quick_action',
@@ -2032,80 +1712,6 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
void this.router.navigateByUrl(route);
}
- setSearchMode(mode: SearchExperienceMode): void {
- if (this.experienceMode() === mode) {
- return;
- }
-
- this.searchExperienceMode.setMode(mode);
- this.recordAmbientAction('search_mode_switch', {
- source: 'global_search_mode_toggle',
- queryHint: mode,
- });
- }
-
- toggleSearchScope(): void {
- const nextScope: SearchScopeMode = this.searchScope() === 'page' ? 'global' : 'page';
- this.searchScope.set(nextScope);
- this.recordAmbientAction('search_scope_toggle', {
- source: 'global_search_scope_toggle',
- queryHint: nextScope,
- });
-
- if (this.query().trim().length > 0) {
- this.searchTerms$.next(this.query().trim());
- }
- }
-
- runRescueAction(actionId: RescueActionView['id']): void {
- const query = this.query().trim();
- if (!query && actionId !== 'reformulate') {
- return;
- }
-
- switch (actionId) {
- case 'scope':
- this.searchScope.set(this.searchScope() === 'page' ? 'global' : 'page');
- this.recordAmbientAction('search_rescue_scope', {
- source: 'global_search_rescue',
- queryHint: this.searchScope(),
- });
- this.searchTerms$.next(query);
- return;
- case 'related': {
- const alternateQuery = this.buildModeAwareAlternativeQuery();
- if (!alternateQuery) {
- return;
- }
-
- this.recordAmbientAction('search_rescue_related_domains', {
- source: 'global_search_rescue',
- queryHint: alternateQuery,
- });
- this.query.set(alternateQuery);
- this.searchTerms$.next(alternateQuery.trim());
- return;
- }
- case 'reformulate':
- this.openAssistantForReformulation();
- return;
- case 'page-context': {
- const contextualQuery = this.buildPageContextRetryQuery();
- if (!contextualQuery) {
- return;
- }
-
- this.recordAmbientAction('search_rescue_page_context', {
- source: 'global_search_rescue',
- queryHint: contextualQuery,
- });
- this.query.set(contextualQuery);
- this.searchTerms$.next(contextualQuery.trim());
- return;
- }
- }
- }
-
setDomainFilter(filter: SearchDomainFilter): void {
this.activeDomainFilter.set(filter);
this.selectedIndex.set(0);
@@ -2244,12 +1850,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
if (!normalized) return;
const next = [normalized, ...this.recentSearches().filter((item) => item !== normalized)].slice(0, 10);
- this.recentSearches.set(next);
- try {
- localStorage.setItem(this.recentSearchStorageKey, JSON.stringify(next));
- } catch {
- // Ignore localStorage failures.
- }
+ this.persistRecentSearches(next);
}
/** Sprint 106 / G6: Load search history from server, merge with localStorage */
@@ -2257,12 +1858,13 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
this.searchClient.getHistory()
.pipe(takeUntil(this.destroy$))
.subscribe((entries) => {
- if (entries.length === 0) return;
+ const successfulEntries = entries.filter((entry) => (entry.resultCount ?? 0) > 0);
+ if (successfulEntries.length === 0) return;
- const serverQueries = entries.map((e) => e.query);
+ const serverQueries = successfulEntries.map((e) => e.query);
const localQueries = this.recentSearches();
const merged = [...new Set([...localQueries, ...serverQueries])].slice(0, 10);
- this.recentSearches.set(merged);
+ this.persistRecentSearches(merged);
});
}
@@ -2328,10 +1930,6 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
return;
}
- if (context.mode) {
- this.searchExperienceMode.setMode(context.mode);
- }
-
const query = context.query.trim();
this.query.set(query);
this.selectedIndex.set(0);
@@ -2346,7 +1944,6 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
entityKey: context.entityKey,
});
this.searchTerms$.next(query);
- this.saveRecentSearch(query);
setTimeout(() => this.searchInputRef?.nativeElement?.focus(), 0);
}
@@ -2368,64 +1965,37 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
setTimeout(() => this.searchInputRef?.nativeElement?.focus(), 0);
}
- private scoreSuggestionForMode(
+ private scoreSuggestion(
suggestion: {
kind?: 'page' | 'recent' | 'strategy';
- preferredModes?: readonly SearchExperienceMode[];
},
- mode: SearchExperienceMode,
): number {
- let score = 0;
- if (suggestion.preferredModes?.includes(mode)) {
- score += 6;
+ switch (suggestion.kind) {
+ case 'recent':
+ return 3;
+ case 'page':
+ return 2;
+ case 'strategy':
+ return 1;
+ default:
+ return 0;
}
-
- if (mode === 'find' && suggestion.kind === 'page') {
- score += 2;
- }
-
- if (mode === 'explain' && suggestion.kind === 'strategy') {
- score += 3;
- }
-
- if (mode === 'act' && suggestion.kind === 'recent') {
- score += 2;
- }
-
- return score;
}
- private scoreQuestionForMode(
+ private scoreQuestion(
question: {
kind?: 'page' | 'clarify' | 'recent';
- preferredModes?: readonly SearchExperienceMode[];
},
- mode: SearchExperienceMode,
): number {
- let score = 0;
- if (question.preferredModes?.includes(mode)) {
- score += 6;
- }
-
- if (mode === 'act' && question.kind === 'recent') {
- score += 2;
- }
-
- if (mode === 'explain' && question.kind === 'page') {
- score += 1;
- }
-
- return score;
- }
-
- private answerTitleForMode(mode: SearchExperienceMode): string {
- switch (mode) {
- case 'explain':
- return this.t('ui.search.answer.title.explain', 'What it means');
- case 'act':
- return this.t('ui.search.answer.title.act', 'Recommended next step');
+ switch (question.kind) {
+ case 'recent':
+ return 3;
+ case 'page':
+ return 2;
+ case 'clarify':
+ return 1;
default:
- return this.t('ui.search.answer.title.find', 'What we found');
+ return 0;
}
}
@@ -2440,14 +2010,6 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
return this.t('ui.search.answer.summary.grounded.default', 'Relevant evidence was found for this query.');
}
- if (this.experienceMode() === 'act') {
- return this.t(
- 'ui.search.answer.summary.grounded.act',
- '{title} is the strongest next lead. Use the result actions below to inspect or act.',
- { title: topCard.title },
- );
- }
-
return topCard.snippet?.trim() || topCard.title;
}
@@ -2487,63 +2049,13 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
}));
}
- private buildModeAwareAlternativeQuery(): string | null {
- const currentQuery = this.query().trim().toLowerCase();
- const mode = this.experienceMode();
- const candidates = this.contextualSuggestions()
- .filter((suggestion) => suggestion.query.trim().toLowerCase() !== currentQuery)
- .sort((left, right) => {
- const leftScore = this.scoreSuggestionForMode(left, mode);
- const rightScore = this.scoreSuggestionForMode(right, mode);
- return rightScore - leftScore;
- });
-
- return candidates[0]?.query ?? null;
- }
-
- private buildPageContextRetryQuery(): string | null {
- const query = this.query().trim();
- const pageLabel = this.searchContextPanel()?.title?.trim();
- if (!query || !pageLabel) {
- return null;
- }
-
- return `${pageLabel} ${query}`.trim();
- }
-
- private openAssistantForReformulation(): void {
+ openAssistantForAnswerPanel(): void {
const query = this.query().trim();
const pageLabel = this.searchContextPanel()?.title ?? 'current page';
- const directive = this.searchExperienceMode.definition().assistantDirective;
- const suggestedPrompt = query
- ? `${directive} Reformulate the search query "${query}" for ${pageLabel} and explain why the reformulation is better.`
- : `${directive} Help me frame a better search for ${pageLabel}.`;
-
- this.recordAmbientAction('search_rescue_reformulate', {
- source: 'global_search_rescue',
- queryHint: query || pageLabel,
- });
- this.searchChatContext.setSearchToChat({
- query: query || pageLabel,
- entityCards: this.filteredCards(),
- synthesis: this.synthesis(),
- suggestedPrompt,
- mode: this.experienceMode(),
- });
- this.closeResults();
- void this.router.navigate(['/security/triage'], {
- queryParams: { openChat: 'true', q: query || pageLabel },
- });
- }
-
- private openAssistantForAnswerPanel(): void {
- const query = this.query().trim();
- const pageLabel = this.searchContextPanel()?.title ?? 'current page';
- const directive = this.searchExperienceMode.definition().assistantDirective;
const answer = this.searchAnswer();
const suggestedPrompt = answer?.status === 'grounded'
- ? `I searched for "${query}" on ${pageLabel}. ${directive} Expand the grounded answer, explain the evidence, and tell me the safest next step.`
- : `I searched for "${query}" on ${pageLabel}. ${directive} Help me clarify the query, explain what evidence is missing, and propose the best next search.`;
+ ? `I searched for "${query}" on ${pageLabel}. Expand the grounded answer, explain the evidence, and tell me the safest next step.`
+ : `I searched for "${query}" on ${pageLabel}. Help me clarify the query, explain what evidence is missing, and propose the best next search.`;
this.recordAmbientAction('search_answer_to_chat', {
source: 'global_search_answer_panel',
@@ -2554,7 +2066,6 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
entityCards: this.cards(),
synthesis: this.synthesis(),
suggestedPrompt,
- mode: this.experienceMode(),
});
this.closeResults();
void this.router.navigate(['/security/triage'], {
@@ -2608,30 +2119,63 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
}
private buildAskAiPromptForCard(card: EntityCard): string {
- const directive = this.searchExperienceMode.definition().assistantDirective;
-
switch (card.domain) {
case 'findings':
- return `${directive} Focus on ${card.title}, why it matters, and the best next step.`;
+ return `Focus on ${card.title}, why it matters, and the best next step.`;
case 'vex':
- return `${directive} Explain this VEX assessment for ${card.title} and the release implications.`;
+ return `Explain this VEX assessment for ${card.title} and the release implications.`;
case 'policy':
- return `${directive} Explain policy rule ${card.title} and what it changes operationally.`;
+ return `Explain policy rule ${card.title} and what it changes operationally.`;
case 'platform':
- return `${directive} Explain platform item ${card.title} and what an operator should do next.`;
+ return `Explain platform item ${card.title} and what an operator should do next.`;
default:
- return `${directive} Summarize ${card.title} and guide me through the next steps.`;
+ return `Summarize ${card.title} and guide me through the next steps.`;
}
}
private buildAskAiPromptForSynthesis(): string {
const query = this.query().trim();
- const directive = this.searchExperienceMode.definition().assistantDirective;
if (!query) {
- return `${directive} I need help understanding these search results and what to do next.`;
+ return 'I need help understanding these search results and what to do next.';
}
- return `I searched for "${query}". ${directive}`;
+ return `I searched for "${query}". Help me understand the evidence and what to do next.`;
+ }
+
+ openAssistantFromSearchBar(): void {
+ const query = this.query().trim();
+ const pageLabel = this.searchContextPanel()?.title ?? 'current page';
+ const suggestedPrompt = query
+ ? `I am on ${pageLabel} and searched for "${query}". Help me understand the best answer and next step.`
+ : `I am on ${pageLabel}. Help me ask the most useful question from here.`;
+
+ this.recordAmbientAction('search_entry_to_chat', {
+ source: 'global_search_entry',
+ queryHint: query || pageLabel,
+ });
+ this.searchChatContext.setSearchToChat({
+ query: query || pageLabel,
+ entityCards: this.cards(),
+ synthesis: this.synthesis(),
+ suggestedPrompt,
+ });
+ this.closeResults();
+ void this.router.navigate(['/security/triage'], {
+ queryParams: { openChat: 'true', q: query || pageLabel },
+ });
+ }
+
+ private hasSearchEvidence(response: UnifiedSearchResponse): boolean {
+ return response.cards.length > 0 || (response.synthesis?.sourceCount ?? 0) > 0;
+ }
+
+ private persistRecentSearches(entries: string[]): void {
+ this.recentSearches.set(entries);
+ try {
+ localStorage.setItem(this.recentSearchStorageKey, JSON.stringify(entries));
+ } catch {
+ // Ignore localStorage failures.
+ }
}
private normalizeActionRoute(route: string): string {
@@ -2667,6 +2211,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
this.recentSearches.set([]);
try {
localStorage.removeItem(this.recentSearchStorageKey);
+ localStorage.removeItem(this.legacyRecentSearchStorageKey);
} catch {
// Ignore localStorage failures.
}
diff --git a/src/Web/StellaOps.Web/src/tests/global_search/global-search.component.spec.ts b/src/Web/StellaOps.Web/src/tests/global_search/global-search.component.spec.ts
index bacf62d0f..c83e6ea01 100644
--- a/src/Web/StellaOps.Web/src/tests/global_search/global-search.component.spec.ts
+++ b/src/Web/StellaOps.Web/src/tests/global_search/global-search.component.spec.ts
@@ -6,7 +6,6 @@ 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 { SearchChatContextService } from '../../app/core/services/search-chat-context.service';
-import { SearchExperienceModeService } from '../../app/core/services/search-experience-mode.service';
import { I18nService } from '../../app/core/i18n';
import { GlobalSearchComponent } from '../../app/layout/global-search/global-search.component';
@@ -18,7 +17,6 @@ describe('GlobalSearchComponent', () => {
let routerEvents: Subject;
let router: { url: string; events: Subject; navigateByUrl: jasmine.Spy; navigate: jasmine.Spy };
let searchChatContext: jasmine.SpyObj;
- let searchExperienceMode: SearchExperienceModeService;
beforeEach(async () => {
localStorage.clear();
@@ -168,7 +166,6 @@ describe('GlobalSearchComponent', () => {
fixture = TestBed.createComponent(GlobalSearchComponent);
component = fixture.componentInstance;
- searchExperienceMode = TestBed.inject(SearchExperienceModeService);
fixture.detectChanges();
});
@@ -302,12 +299,12 @@ describe('GlobalSearchComponent', () => {
it('navigates to assistant host with openChat intent from Ask AI card action', () => {
const card = createCard('findings', '/triage/findings/fnd-1');
- searchExperienceMode.setMode('act');
component.onAskAiFromCard(card);
expect(searchChatContext.setSearchToChat).toHaveBeenCalledWith(jasmine.objectContaining({
- mode: 'act',
+ query: 'findings sample',
+ suggestedPrompt: jasmine.stringMatching(/why it matters/i),
}));
expect(router.navigate).toHaveBeenCalledWith(
['/security/triage'],
@@ -413,30 +410,30 @@ describe('GlobalSearchComponent', () => {
expect(component.isFocused()).toBeTrue();
});
- it('keeps the search panel open when focus moves into experience controls', async () => {
+ it('keeps the search panel open when focus moves to the chat launcher', async () => {
component.onFocus();
component.onQueryChange('critical findings');
await waitForDebounce();
fixture.detectChanges();
- const explainButton = fixture.nativeElement.querySelectorAll('.search__segment')[1] as HTMLButtonElement | undefined;
- expect(explainButton).toBeDefined();
+ const chatLauncher = fixture.nativeElement.querySelector('.search__chat-launcher') as HTMLButtonElement | null;
+ expect(chatLauncher).not.toBeNull();
- explainButton!.focus();
+ chatLauncher!.focus();
component.onBlur();
await new Promise((resolve) => setTimeout(resolve, 250));
expect(component.isFocused()).toBeTrue();
});
- it('renders rescue actions when a query returns no results', async () => {
+ it('does not render recovery actions when a query returns no results', async () => {
component.onFocus();
component.onQueryChange('no results');
await waitForDebounce();
fixture.detectChanges();
const rescueCards = fixture.nativeElement.querySelectorAll('.search__rescue-card');
- expect(rescueCards.length).toBe(4);
+ expect(rescueCards.length).toBe(0);
});
it('renders a grounded answer panel before search results', async () => {
@@ -498,40 +495,31 @@ describe('GlobalSearchComponent', () => {
expect(answerQuestions).toContain('Should I focus on reachable, production, or unresolved findings?');
});
- it('retries the active query globally when scope rescue toggles off page filtering', async () => {
+ it('does not hard-filter search requests to the current route scope', async () => {
ambientContext.buildContextFilter.and.returnValue({ domains: ['findings'] } as any);
component.onFocus();
component.onQueryChange('critical findings');
await waitForDebounce();
- const scopedCall = searchClient.search.calls.mostRecent();
- expect(scopedCall).toBeDefined();
- expect(scopedCall!.args[1]).toEqual({ domains: ['findings'] });
-
- searchClient.search.calls.reset();
- component.runRescueAction('scope');
- await waitForDebounce();
-
- const globalCall = searchClient.search.calls.mostRecent();
- expect(globalCall).toBeDefined();
- expect(globalCall!.args[1]).toBeUndefined();
- expect(ambientContext.recordAction).toHaveBeenCalledWith(jasmine.objectContaining({
- action: 'search_rescue_scope',
- }));
+ const searchCall = searchClient.search.calls.mostRecent();
+ expect(searchCall).toBeDefined();
+ expect(searchCall!.args[1]).toBeUndefined();
});
- it('opens AdvisoryAI reformulation with the current mode and query context', () => {
- searchExperienceMode.setMode('explain');
+ it('opens AdvisoryAI from the search bar with page and query context', () => {
component.onFocus();
component.query.set('mismatch');
- component.runRescueAction('reformulate');
+ const chatLauncher = fixture.nativeElement.querySelector('.search__chat-launcher') as HTMLButtonElement | null;
+ expect(chatLauncher).not.toBeNull();
+
+ chatLauncher!.click();
+ fixture.detectChanges();
expect(searchChatContext.setSearchToChat).toHaveBeenCalledWith(jasmine.objectContaining({
query: 'mismatch',
- mode: 'explain',
- suggestedPrompt: jasmine.stringMatching(/Reformulate the search query "mismatch"/),
+ suggestedPrompt: jasmine.stringMatching(/searched for "mismatch"/i),
}));
expect(router.navigate).toHaveBeenCalledWith(
['/security/triage'],
@@ -544,24 +532,67 @@ describe('GlobalSearchComponent', () => {
);
});
- it('drops the route filter when search scope is toggled to global', async () => {
+ it('does not render explicit mode or scope controls', async () => {
ambientContext.buildContextFilter.and.returnValue({ domains: ['findings'] } as any);
component.onFocus();
component.onQueryChange('CVE-2024-21626');
await waitForDebounce();
- const pageScopedCall = searchClient.search.calls.mostRecent();
- expect(pageScopedCall).toBeDefined();
- expect(pageScopedCall!.args[1]).toEqual({ domains: ['findings'] });
- searchClient.search.calls.reset();
- component.toggleSearchScope();
- await waitForDebounce();
+ expect(fixture.nativeElement.querySelector('.search__segment')).toBeNull();
+ expect(fixture.nativeElement.querySelector('.search__scope-chip')).toBeNull();
+ });
- expect(component.searchScope()).toBe('global');
- const unscopedCall = searchClient.search.calls.mostRecent();
- expect(unscopedCall).toBeDefined();
- expect(unscopedCall!.args[1]).toBeUndefined();
+ it('filters zero-result entries from server-backed recent history', () => {
+ searchClient.getHistory.and.returnValue(of([
+ {
+ historyId: 'hist-1',
+ query: 'critical findings',
+ resultCount: 3,
+ createdAt: '2026-03-07T10:00:00Z',
+ },
+ {
+ historyId: 'hist-2',
+ query: 'database connectivity',
+ resultCount: 0,
+ createdAt: '2026-03-07T10:01:00Z',
+ },
+ ] as any));
+
+ component.onFocus();
+ fixture.detectChanges();
+
+ expect(component.recentSearches()).toEqual(['critical findings']);
+ });
+
+ it('ignores legacy mixed-result local history keys and persists the successful-only key', () => {
+ localStorage.setItem('stella-recent-searches', JSON.stringify(['old zero result', 'old success']));
+ searchClient.search.and.returnValue(of({
+ query: 'critical findings',
+ topK: 10,
+ cards: [createCard('findings', '/triage/findings/fnd-legacy')],
+ synthesis: null,
+ diagnostics: {
+ ftsMatches: 1,
+ vectorMatches: 0,
+ entityCardCount: 1,
+ durationMs: 2,
+ usedVector: false,
+ mode: 'fts-only',
+ },
+ }));
+
+ component.onFocus();
+ fixture.detectChanges();
+
+ expect(component.recentSearches()).toEqual([]);
+
+ component.onQueryChange('critical findings');
+
+ return waitForDebounce().then(() => {
+ expect(JSON.parse(localStorage.getItem('stella-successful-searches-v2') ?? '[]')).toEqual(['critical findings']);
+ expect(localStorage.getItem('stella-recent-searches')).toBe(JSON.stringify(['old zero result', 'old success']));
+ });
});
function createCard(domain: EntityCard['domain'], route: string): EntityCard {
diff --git a/src/Web/StellaOps.Web/tests/e2e/unified-search-experience-quality.e2e.spec.ts b/src/Web/StellaOps.Web/tests/e2e/unified-search-experience-quality.e2e.spec.ts
index 93db69530..2def45908 100644
--- a/src/Web/StellaOps.Web/tests/e2e/unified-search-experience-quality.e2e.spec.ts
+++ b/src/Web/StellaOps.Web/tests/e2e/unified-search-experience-quality.e2e.spec.ts
@@ -31,23 +31,10 @@ const criticalFindingResponse = buildResponse(
},
);
-const broadenedScopeResponse = buildResponse(
- 'scope sensitive outage',
- [
- policyCard({
- ruleId: 'DENY-CRITICAL-PROD',
- title: 'DENY-CRITICAL-PROD',
- snippet: 'Production deny rule linked to the active incident.',
- }),
- ],
- {
- summary: 'The broader search found a policy blocker outside the page scope.',
- template: 'policy_overview',
- confidence: 'high',
- sourceCount: 1,
- domainsCovered: ['policy'],
- },
-);
+const correctionResponse = {
+ ...emptyResponse('critcal findings'),
+ suggestions: [{ text: 'critical findings', reason: 'Close match in the active corpus.' }],
+};
const policyBlockerResponse = buildResponse(
'policy blockers for CVE-2024-21626',
@@ -73,7 +60,8 @@ test.describe('Unified Search - Experience Quality UX', () => {
await setupAuthenticatedSession(page);
});
- test('keeps keyboard-selected mode when handing off from search to AdvisoryAI', async ({ page }) => {
+ test('opens AdvisoryAI from the secondary search-bar launcher with page and query context', async ({ page }) => {
+ const capturedTurnBodies: Array> = [];
await mockSearchResponses(page, (query) => {
if (query.includes('critical findings')) {
return criticalFindingResponse;
@@ -82,9 +70,10 @@ test.describe('Unified Search - Experience Quality UX', () => {
return emptyResponse(query);
});
await mockChatConversation(page, {
- content: 'AdvisoryAI is ready to explain the finding and cite evidence.',
+ content: 'AdvisoryAI is ready to expand the answer and explain the next step.',
citations: [{ type: 'finding', path: 'CVE-2024-21626', verified: true }],
groundingScore: 0.94,
+ onTurnCreate: (body) => capturedTurnBodies.push(body),
});
await page.goto('/security/triage');
@@ -94,37 +83,25 @@ test.describe('Unified Search - Experience Quality UX', () => {
await waitForResults(page);
await waitForEntityCards(page, 1);
- await page.locator('app-global-search input[type="text"]').focus();
- await waitForResults(page);
-
- const explainButton = page.locator('.search__experience-bar').getByRole('button', { name: 'Explain' });
- await explainButton.focus();
- await explainButton.press('Enter');
- await expect(explainButton).toHaveClass(/search__segment--active/);
-
- await page.locator('app-global-search input[type="text"]').focus();
- await waitForResults(page);
- await page.locator('.entity-card__action--ask-ai').first().click();
+ await page.locator('.search__chat-launcher').click();
await expect(page.locator('.assistant-drawer')).toBeVisible({ timeout: 10_000 });
- await expect(page.locator('.chat-mode-btn--active')).toHaveText(/Explain/i);
+ await expect(page.locator('app-global-search input[type="text"]')).toHaveValue('');
+ await expect.poll(() => capturedTurnBodies.length).toBeGreaterThan(0);
+
+ const turnBody = capturedTurnBodies.at(-1) ?? {};
+ expect(String(turnBody['content'] ?? '')).toMatch(/critical findings/i);
});
- test('broadens zero-result searches to all domains and reruns the same query', async ({ page }) => {
+ test('renders did-you-mean directly below the search bar and removes teaching controls', async ({ page }) => {
const capturedRequests: Array> = [];
await page.route('**/search/query**', async (route) => {
const request = route.request().postDataJSON() as Record;
capturedRequests.push(request);
-
const query = String(request['q'] ?? '').toLowerCase();
- const filters = request['filters'] as Record | undefined;
- const hasPageScope = Array.isArray(filters?.['domains']) && filters!['domains'].length > 0;
-
- const response = query.includes('scope sensitive outage')
- ? hasPageScope
- ? emptyResponse('scope sensitive outage')
- : broadenedScopeResponse
- : emptyResponse(query);
+ const response = query.includes('critcal findings')
+ ? correctionResponse
+ : criticalFindingResponse;
return route.fulfill({
status: 200,
@@ -136,28 +113,63 @@ test.describe('Unified Search - Experience Quality UX', () => {
await page.goto('/security/triage');
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
- await typeInSearch(page, 'scope sensitive outage');
+ await typeInSearch(page, 'critcal findings');
await waitForResults(page);
- await expect(page.locator('.search__empty')).toContainText(/no results found/i);
- await expect(page.locator('.search__rescue-card')).toHaveCount(4);
- await page.locator('[data-rescue-action="scope"]').click();
- await page.locator('app-global-search input[type="text"]').focus();
- await waitForResults(page);
- await expect(page.locator('[data-role="search-scope"]')).toContainText(/All domains/i);
- await expect(page.locator('app-global-search input[type="text"]')).toHaveValue('scope sensitive outage');
- await expect(page.locator('.search__cards')).toContainText(/DENY-CRITICAL-PROD/i);
+ const searchBar = page.locator('.search__input-wrapper');
+ const didYouMean = page.locator('.did-you-mean--inline');
- expect(capturedRequests[0]?.['q']).toBe('scope sensitive outage');
+ await expect(didYouMean).toBeVisible();
+ await expect(page.locator('.search__segment')).toHaveCount(0);
+ await expect(page.locator('.search__scope-chip')).toHaveCount(0);
+ await expect(page.locator('.search__rescue-card')).toHaveCount(0);
+
+ const searchBarBox = await searchBar.boundingBox();
+ const didYouMeanBox = await didYouMean.boundingBox();
+ expect(searchBarBox).not.toBeNull();
+ expect(didYouMeanBox).not.toBeNull();
+ expect(didYouMeanBox!.y).toBeGreaterThan(searchBarBox!.y);
+ expect(didYouMeanBox!.y - (searchBarBox!.y + searchBarBox!.height)).toBeLessThan(20);
+
+ const request = capturedRequests[0] ?? {};
+ expect(request['filters']).toBeUndefined();
});
- test('opens AdvisoryAI reformulation from the zero-result rescue flow', async ({ page }) => {
- await mockSearchResponses(page, (query) =>
- query.includes('mystery remediation') ? emptyResponse('mystery remediation') : emptyResponse(query));
- await mockChatConversation(page, {
- content: 'I can reformulate that query for better recall.',
- citations: [{ type: 'docs', path: 'modules/ui/search-chip-context-contract.md', verified: true }],
- groundingScore: 0.91,
+ test('shows only successful history entries and clears them with the icon action', async ({ page }) => {
+ let historyCleared = false;
+ await mockSearchResponses(page, (query) => {
+ if (query.includes('critical findings')) {
+ return criticalFindingResponse;
+ }
+
+ return emptyResponse(query);
+ });
+ await page.route('**/api/v1/advisory-ai/search/history', async (route) => {
+ if (route.request().method() === 'DELETE') {
+ historyCleared = true;
+ return route.fulfill({ status: 204, body: '' });
+ }
+
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ entries: [
+ {
+ historyId: 'history-1',
+ query: 'critical findings',
+ resultCount: 2,
+ createdAt: '2026-03-07T11:00:00Z',
+ },
+ {
+ historyId: 'history-2',
+ query: 'database connectivity',
+ resultCount: 0,
+ createdAt: '2026-03-07T11:01:00Z',
+ },
+ ],
+ }),
+ });
});
await page.goto('/security/triage');
@@ -165,18 +177,19 @@ test.describe('Unified Search - Experience Quality UX', () => {
await page.locator('app-global-search input[type="text"]').focus();
await waitForResults(page);
- await page.locator('.search__experience-bar').getByRole('button', { name: 'Explain' }).click();
- await typeInSearch(page, 'mystery remediation');
- await waitForResults(page);
- await expect(page.locator('.search__empty')).toContainText(/no results found/i);
- await page.locator('[data-rescue-action="reformulate"]').click();
+ await expect(page.locator('.search__group').filter({ hasText: 'Recent' })).toContainText('critical findings');
+ await expect(page.locator('.search__group').filter({ hasText: 'Recent' })).not.toContainText('database connectivity');
- await expect(page.locator('.assistant-drawer')).toBeVisible({ timeout: 10_000 });
- await expect(page.locator('.chat-mode-btn--active')).toHaveText(/Explain/i);
- await expect(page.locator('.chat-message.user .message-body').first()).toContainText(
- /Reformulate the search query "mystery remediation"/i,
- );
+ const clearButton = page.locator('.search__clear-history');
+ await expect(clearButton).toBeVisible();
+ await expect(clearButton.locator('svg')).toBeVisible();
+ await expect(clearButton).toHaveText('');
+
+ await clearButton.click();
+
+ await expect.poll(() => historyCleared).toBe(true);
+ await expect(page.locator('.search__group').filter({ hasText: 'Recent' })).toHaveCount(0);
});
test('returns structured next-step policy searches back into global search with metadata', async ({ page }) => {
@@ -211,11 +224,6 @@ test.describe('Unified Search - Experience Quality UX', () => {
await typeInSearch(page, 'critical findings');
await waitForResults(page);
await waitForEntityCards(page, 1);
- await page.locator('app-global-search input[type="text"]').focus();
- await waitForResults(page);
- await page.locator('.search__experience-bar').getByRole('button', { name: 'Act' }).click();
- await page.locator('app-global-search input[type="text"]').focus();
- await waitForResults(page);
await page.locator('.entity-card__action--ask-ai').first().click();
await expect(page.locator('.assistant-drawer')).toBeVisible({ timeout: 10_000 });
@@ -226,10 +234,10 @@ test.describe('Unified Search - Experience Quality UX', () => {
await expect(page.locator('.assistant-drawer')).toBeHidden({ timeout: 10_000 });
await waitForResults(page);
await waitForEntityCards(page, 1);
- await expect(page.locator('app-global-search input[type="text"]')).toHaveValue(/policy blockers for CVE-2024-21626/i);
+ await expect(page.locator('app-global-search input[type="text"]')).toHaveValue(/policy .*CVE-2024-21626/i);
const policyRequest = capturedRequests.find((request) =>
- String(request['q'] ?? '').toLowerCase().includes('policy blockers for cve-2024-21626'));
+ /policy .*cve-2024-21626/i.test(String(request['q'] ?? '')));
const ambient = policyRequest?.['ambient'] as Record | undefined;
const lastAction = ambient?.['lastAction'] as Record | undefined;
@@ -260,6 +268,8 @@ async function mockChatConversation(
content: string;
citations: Array<{ type: string; path: string; verified: boolean }>;
groundingScore: number;
+ onConversationCreate?: (body: Record) => void;
+ onTurnCreate?: (body: Record) => void;
},
): Promise {
await page.route('**/api/v1/advisory-ai/conversations', async (route) => {
@@ -271,6 +281,9 @@ async function mockChatConversation(
});
}
+ const requestBody = (route.request().postDataJSON() as Record | null) ?? {};
+ response.onConversationCreate?.(requestBody);
+
return route.fulfill({
status: 200,
contentType: 'application/json',
@@ -291,6 +304,9 @@ async function mockChatConversation(
return route.continue();
}
+ const requestBody = (route.request().postDataJSON() as Record | null) ?? {};
+ response.onTurnCreate?.(requestBody);
+
const events = [
'event: progress',
'data: {"stage":"searching"}',
diff --git a/src/Web/StellaOps.Web/tests/e2e/unified-search-self-serve-answer-panel.e2e.spec.ts b/src/Web/StellaOps.Web/tests/e2e/unified-search-self-serve-answer-panel.e2e.spec.ts
index 27f597433..7769b1eaa 100644
--- a/src/Web/StellaOps.Web/tests/e2e/unified-search-self-serve-answer-panel.e2e.spec.ts
+++ b/src/Web/StellaOps.Web/tests/e2e/unified-search-self-serve-answer-panel.e2e.spec.ts
@@ -133,21 +133,20 @@ test.describe('Unified Search - Self-Serve Answer Panel', () => {
);
});
- test('opens AdvisoryAI from the answer panel with mode-aware context', async ({ page }) => {
+ test('opens AdvisoryAI from the answer panel with grounded answer context', async ({ page }) => {
+ const capturedTurnBodies: Array> = [];
await mockSearchResponses(page, (query) =>
query.includes('critical findings') ? groundedFindingResponse : emptyResponse(query));
await mockChatConversation(page, {
content: 'I can expand the grounded answer, explain the evidence, and recommend the safest next step.',
citations: [{ type: 'finding', path: 'CVE-2024-21626', verified: true }],
groundingScore: 0.95,
+ onTurnCreate: (body) => capturedTurnBodies.push(body),
});
await page.goto('/security/triage');
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
- await page.locator('app-global-search input[type="text"]').focus();
- await waitForResults(page);
- await page.locator('.search__experience-bar').getByRole('button', { name: 'Act' }).click();
await typeInSearch(page, 'critical findings');
await waitForResults(page);
await waitForEntityCards(page, 1);
@@ -155,9 +154,11 @@ test.describe('Unified Search - Self-Serve Answer Panel', () => {
await page.locator('[data-answer-action="ask-ai"]').click();
await expect(page.locator('.assistant-drawer')).toBeVisible({ timeout: 10_000 });
- await expect(page.locator('.chat-mode-btn--active')).toHaveText(/Act/i);
await expect(page.locator('.chat-message.user .message-body').first()).toContainText(/Expand the grounded answer/i);
await expect(page.locator('.chat-message.user .message-body').first()).toContainText(/critical findings/i);
+ await expect.poll(() => capturedTurnBodies.length).toBeGreaterThan(0);
+ expect(String(capturedTurnBodies.at(-1)?.['content'] ?? '')).toMatch(/Expand the grounded answer/i);
+ expect(String(capturedTurnBodies.at(-1)?.['content'] ?? '')).toMatch(/critical findings/i);
});
});
@@ -182,6 +183,7 @@ async function mockChatConversation(
content: string;
citations: Array<{ type: string; path: string; verified: boolean }>;
groundingScore: number;
+ onTurnCreate?: (body: Record) => void;
},
): Promise {
await page.route('**/api/v1/advisory-ai/conversations', async (route) => {
@@ -213,6 +215,9 @@ async function mockChatConversation(
return route.continue();
}
+ const requestBody = (route.request().postDataJSON() as Record | null) ?? {};
+ response.onTurnCreate?.(requestBody);
+
const events = [
'event: progress',
'data: {"stage":"searching"}',