Consolidate search-first shell UX
This commit is contained in:
@@ -19,7 +19,7 @@
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-SF-001 - Consolidate shell language around search-first entry
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: Developer
|
||||
Task description:
|
||||
@@ -27,12 +27,12 @@ Task description:
|
||||
- Keep the compact assistant icon beside the search field, but use secondary "details/deeper help" language in the primary surface.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] The top-bar search surface reads as one workflow with a secondary assistant action.
|
||||
- [ ] Answer-panel and launcher labels no longer imply a competing primary entry model.
|
||||
- [ ] No search-origin assistant action performs a route jump.
|
||||
- [x] The top-bar search surface reads as one workflow with a secondary assistant action.
|
||||
- [x] Answer-panel and launcher labels no longer imply a competing primary entry model.
|
||||
- [x] No search-origin assistant action performs a route jump.
|
||||
|
||||
### FE-SF-002 - Make automatic result shaping clearer than mechanics
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: FE-SF-001
|
||||
Owners: Developer
|
||||
Task description:
|
||||
@@ -40,12 +40,12 @@ Task description:
|
||||
- Keep `Did you mean` directly below the input and avoid any scope or refinement teaching copy.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] `Did you mean` stays input-adjacent.
|
||||
- [ ] Overflow is visibly secondary and uses plain operator-facing labels.
|
||||
- [ ] No recovery/refinement mechanics are rendered in the primary flow.
|
||||
- [x] `Did you mean` stays input-adjacent.
|
||||
- [x] Overflow is visibly secondary and uses plain operator-facing labels.
|
||||
- [x] No recovery/refinement mechanics are rendered in the primary flow.
|
||||
|
||||
### FE-SF-003 - Tighten suggestion handling and successful history behavior
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: FE-SF-002
|
||||
Owners: Developer
|
||||
Task description:
|
||||
@@ -53,12 +53,12 @@ Task description:
|
||||
- Keep history success-only and make the clear-history affordance remain low-emphasis.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Starter chips disappear when backend viability marks them non-executable.
|
||||
- [ ] No-result searches never persist into history.
|
||||
- [ ] The clear-history action remains a discreet icon treatment.
|
||||
- [x] Starter chips disappear when backend viability marks them non-executable.
|
||||
- [x] No-result searches never persist into history.
|
||||
- [x] The clear-history action remains a discreet icon treatment.
|
||||
|
||||
### FE-SF-004 - Verify the shell consolidation paths
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: FE-SF-003
|
||||
Owners: Developer, Test Automation
|
||||
Task description:
|
||||
@@ -66,18 +66,20 @@ Task description:
|
||||
- Cover suggestion execution and no-result history behavior in the FE surface.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Angular tests cover updated labels, answer/overflow presentation, and success-only history behavior.
|
||||
- [ ] Playwright covers search-first entry, assistant launch from search, and suggestion execution on the simplified shell.
|
||||
- [ ] No visible mode or scope controls appear in covered flows.
|
||||
- [x] Angular tests cover updated labels, answer/overflow presentation, and success-only history behavior.
|
||||
- [x] Playwright covers search-first entry, assistant launch from search, and suggestion execution on the simplified shell.
|
||||
- [x] No visible mode or scope controls appear in covered flows.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-07 | Sprint created for the FE shell consolidation half of the final search-first correction pass. | Project Manager |
|
||||
| 2026-03-07 | Reduced primary-surface assistant branding to secondary "deeper help/details" language, relabeled the drawer to `Search assistant`, tightened overflow wording, and added self-correcting starter suppression for suggestion chips that execute to no useful result. Verification: `npm test -- --include src/tests/global_search/global-search.component.spec.ts --include src/tests/advisory_ai_chat/chat.component.spec.ts --include src/tests/advisory_ai_chat/chat-message.component.spec.ts` -> `34/34` passed; `npx playwright test tests/e2e/unified-search-experience-quality.e2e.spec.ts tests/e2e/unified-search-self-serve-answer-panel.e2e.spec.ts tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts --config playwright.config.ts` -> `16/16` passed. | Developer / Test Automation |
|
||||
|
||||
## Decisions & Risks
|
||||
- Decision: assistant remains available but secondary; search is the first-class workflow.
|
||||
- Decision: current-scope weighting is communicated through answer ordering, not control surfaces.
|
||||
- Decision: starter chips that fail at execution time are immediately suppressed from the current page context instead of being shown again as if they were trustworthy.
|
||||
- Risk: shell-level copy and layout may still imply a separate assistant product even after earlier cleanup.
|
||||
- Mitigation: change the primary-surface labels and add focused FE regression coverage.
|
||||
- Reference: `docs/modules/ui/search-zero-learning-primary-entry.md`
|
||||
@@ -9,8 +9,6 @@
|
||||
- `docs/implplan/SPRINT_20260307_009_DOCS_ui_component_preservation_map.md`
|
||||
- `docs/implplan/SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md`
|
||||
- `docs/implplan/SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md`
|
||||
- `docs/implplan/SPRINT_20260307_035_DOCS_search_first_final_correction_phases.md`
|
||||
- `docs/implplan/SPRINT_20260307_036_FE_search_first_shell_consolidation.md`
|
||||
- `docs/implplan/SPRINT_20260307_038_FE_live_search_supported_route_execution_matrix.md`
|
||||
|
||||
## Delivery Tasks
|
||||
@@ -50,10 +48,10 @@
|
||||
- [DONE] FE-UX-E2E Playwright coverage for mode switching, rescue flows, and AdvisoryAI next-step cards
|
||||
- [DONE] WEB-CTX-NONOBVIOUS Strategic non-obvious suggestion recipes (cross-domain + action-aware)
|
||||
- [DOING] FE-QA-LOOP-001 Web-only Playwright full-iteration loop at stella-ops.local (fresh route/action evidence, triage, fix, retest)
|
||||
- [TODO] FE-SF-001 Search-first shell language consolidation
|
||||
- [TODO] FE-SF-002 Automatic answer/overflow presentation cleanup
|
||||
- [TODO] FE-SF-003 Suggestion execution and success-only history hardening
|
||||
- [TODO] FE-SF-004 Search-first shell verification coverage
|
||||
- [DONE] FE-SF-001 Search-first shell language consolidation
|
||||
- [DONE] FE-SF-002 Automatic answer/overflow presentation cleanup
|
||||
- [DONE] FE-SF-003 Suggestion execution and success-only history hardening
|
||||
- [DONE] FE-SF-004 Search-first shell verification coverage
|
||||
- [TODO] QA-SF-001 Live route preflight and corpus readiness gate
|
||||
- [TODO] QA-SF-002 Execute surfaced suggestions on supported routes
|
||||
- [TODO] QA-SF-003 Align deterministic and live search-first matrices
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
- Implemented before the corrective phases: the live Doctor suggestion suite now rebuilds the active corpus, fails on empty knowledge projections, iterates every surfaced suggestion, and verifies Ask-AdvisoryAI inherits the live search context.
|
||||
- Implemented from the corrective phases: backend overflow is now narrow enough that clear in-scope winners suppress out-of-scope spillover, blended summaries only appear for genuinely close evidence clusters, and `SearchTelemetryEnabled` cleanly disables analytics/feedback/sink emission without affecting retrieval or history.
|
||||
- Implemented from the operator-correction pass: FE search contracts no longer depend on hidden `Find / Explain / Act` metadata, starter chips wait for backend viability before rendering, `Did you mean` is the first in-panel cue under the search field, and successful recent history now uses a structured `stella-successful-searches-v3` contract that ignores legacy bare-string entries on load.
|
||||
- Implemented from the final correction pass: the primary surface now uses secondary "deeper help/details" assistant language instead of presenting a separate AdvisoryAI product, overflow results read as supporting context, and starter chips that execute to no useful result are suppressed from the current page until context changes.
|
||||
- Still pending from the corrective phases: stricter backend viability states across more domains, broader live-page matrices, and explicit client-side telemetry opt-out.
|
||||
|
||||
## Execution phases - operator correction pass
|
||||
|
||||
@@ -79,7 +79,7 @@ interface NextStepCard {
|
||||
<!-- Message content -->
|
||||
<div class="message-content">
|
||||
<header class="message-header">
|
||||
<span class="message-role">{{ turn.role === 'user' ? 'You' : 'AdvisoryAI' }}</span>
|
||||
<span class="message-role">{{ turn.role === 'user' ? 'You' : 'Assistant' }}</span>
|
||||
<time class="message-time" [attr.datetime]="turn.timestamp">
|
||||
{{ formatTime(turn.timestamp) }}
|
||||
</time>
|
||||
|
||||
@@ -55,7 +55,7 @@ import {
|
||||
<path d="M16 15.5v.01"/>
|
||||
<path d="M12 12v.01"/>
|
||||
</svg>
|
||||
<h2 class="header-title">AdvisoryAI</h2>
|
||||
<h2 class="header-title">Search assistant</h2>
|
||||
@if (conversation()) {
|
||||
<span class="conversation-id">{{ conversation()!.conversationId.substring(0, 8) }}</span>
|
||||
}
|
||||
@@ -110,7 +110,7 @@ import {
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<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>
|
||||
<h3>Go deeper</h3>
|
||||
<p>{{ emptyStateDescription() }}</p>
|
||||
<div class="suggestions">
|
||||
@for (suggestion of suggestions(); track suggestion) {
|
||||
@@ -144,7 +144,7 @@ import {
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<header class="message-header">
|
||||
<span class="message-role">AdvisoryAI</span>
|
||||
<span class="message-role">Assistant</span>
|
||||
<span class="typing-indicator">
|
||||
@if (progressStage()) {
|
||||
{{ progressStage() }}
|
||||
|
||||
@@ -49,6 +49,7 @@ type SearchStarterView = {
|
||||
query: string;
|
||||
kind: 'question' | 'suggestion';
|
||||
};
|
||||
type SuggestedExecutionSource = 'starter' | 'question' | 'answer-next' | 'did-you-mean';
|
||||
type SearchContextPanelView = {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -106,8 +107,8 @@ type SuccessfulSearchHistoryEntry = {
|
||||
<button
|
||||
type="button"
|
||||
class="search__chat-launcher"
|
||||
[attr.aria-label]="t('ui.search.chat_launcher', 'Open AdvisoryAI chat')"
|
||||
[title]="t('ui.search.chat_launcher', 'Open AdvisoryAI chat')"
|
||||
[attr.aria-label]="t('ui.search.chat_launcher', 'Open deeper help')"
|
||||
[title]="t('ui.search.chat_launcher', 'Open deeper help')"
|
||||
(mousedown)="$event.preventDefault()"
|
||||
(click)="openAssistantFromSearchBar()"
|
||||
>
|
||||
@@ -162,7 +163,7 @@ type SuccessfulSearchHistoryEntry = {
|
||||
data-answer-action="ask-ai"
|
||||
(click)="openAssistantForAnswerPanel()"
|
||||
>
|
||||
{{ t('ui.search.answer.ask_ai', 'Ask AdvisoryAI') }}
|
||||
{{ t('ui.search.answer.ask_ai', 'Open details') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="search__answer-evidence">{{ answer.evidence }}</div>
|
||||
@@ -328,7 +329,7 @@ type SuccessfulSearchHistoryEntry = {
|
||||
|
||||
@if (starterQueries().length > 0) {
|
||||
<div class="search__suggestions">
|
||||
<div class="search__group-label">{{ t('ui.search.starters.label', 'Try asking') }}</div>
|
||||
<div class="search__group-label">{{ t('ui.search.starters.label', 'Start here') }}</div>
|
||||
<div class="search__starter-chips">
|
||||
@for (starter of starterQueries(); track starter.query) {
|
||||
<button
|
||||
@@ -927,6 +928,9 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
private readonly recentSearchStorageKey = 'stella-successful-searches-v3';
|
||||
private readonly legacySuccessfulSearchStorageKey = 'stella-successful-searches-v2';
|
||||
private readonly legacyRecentSearchStorageKey = 'stella-recent-searches';
|
||||
private pendingSuggestedExecution:
|
||||
| { query: string; source: SuggestedExecutionSource }
|
||||
| null = null;
|
||||
private wasDegradedMode = false;
|
||||
private escapeCount = 0;
|
||||
private placeholderRotationHandle: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -947,6 +951,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
readonly searchResponse = signal<UnifiedSearchResponse | null>(null);
|
||||
readonly suggestionViability = signal<SearchSuggestionViabilityResponse | null>(null);
|
||||
readonly recentSearches = signal<string[]>([]);
|
||||
readonly suppressedStarterQueries = signal<string[]>([]);
|
||||
readonly expandedCardKey = signal<string | null>(null);
|
||||
|
||||
readonly showResults = computed(() =>
|
||||
@@ -1092,7 +1097,10 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
readonly contextualSuggestions = computed<SearchSuggestionView[]>(() => {
|
||||
return [...this.ambientContext.getSearchSuggestions()]
|
||||
.sort((left, right) => this.scoreSuggestion(right) - this.scoreSuggestion(left))
|
||||
.filter((suggestion) => this.isSuggestionQueryViable(this.i18n.tryT(suggestion.key) ?? suggestion.fallback))
|
||||
.filter((suggestion) => {
|
||||
const query = this.i18n.tryT(suggestion.key) ?? suggestion.fallback;
|
||||
return this.isSuggestionQueryViable(query) && !this.isStarterQuerySuppressed(query);
|
||||
})
|
||||
.map((suggestion) => ({
|
||||
query: this.i18n.tryT(suggestion.key) ?? suggestion.fallback,
|
||||
reason: this.resolveSuggestionReason(suggestion),
|
||||
@@ -1103,7 +1111,10 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
readonly commonQuestions = computed<SearchQuestionView[]>(() => {
|
||||
return [...this.ambientContext.getCommonQuestions()]
|
||||
.sort((left, right) => this.scoreQuestion(right) - this.scoreQuestion(left))
|
||||
.filter((question) => this.isSuggestionQueryViable(this.i18n.tryT(question.key) ?? question.fallback))
|
||||
.filter((question) => {
|
||||
const query = this.i18n.tryT(question.key) ?? question.fallback;
|
||||
return this.isSuggestionQueryViable(query) && !this.isStarterQuerySuppressed(query);
|
||||
})
|
||||
.map((question) => ({
|
||||
query: this.i18n.tryT(question.key) ?? question.fallback,
|
||||
kind: question.kind ?? 'page',
|
||||
@@ -1165,7 +1176,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
return {
|
||||
status: 'grounded',
|
||||
eyebrow,
|
||||
title: this.t('ui.search.answer.title.find', 'What we found'),
|
||||
title: this.t('ui.search.answer.title.find', 'Best answer'),
|
||||
summary: this.buildGroundedAnswerSummary(response),
|
||||
evidence: this.buildGroundedEvidenceLabel(response),
|
||||
citations: this.buildAnswerCitations(response),
|
||||
@@ -1269,7 +1280,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
}));
|
||||
});
|
||||
readonly overflowSectionTitle = computed(() =>
|
||||
this.t('ui.search.results.overflow', 'Also worth checking'));
|
||||
this.t('ui.search.results.overflow', 'Also relevant elsewhere'));
|
||||
readonly overflowSectionReason = computed(() =>
|
||||
this.searchResponse()?.overflow?.reason
|
||||
?? this.t(
|
||||
@@ -1295,6 +1306,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.consumeChatToSearchContext();
|
||||
this.clearSuppressedStarterQueries();
|
||||
if (this.isFocused()) {
|
||||
this.refreshSuggestionViability();
|
||||
}
|
||||
@@ -1355,6 +1367,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
Math.max(1, response.cards.length + overflowCount),
|
||||
);
|
||||
}
|
||||
this.reconcileSuggestedExecution(response);
|
||||
|
||||
// Sprint 106 / G6: Emit search analytics events
|
||||
this.emitSearchAnalytics(response);
|
||||
@@ -1415,6 +1428,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
this.query.set(value);
|
||||
this.selectedIndex.set(0);
|
||||
this.escapeCount = 0;
|
||||
this.pendingSuggestedExecution = null;
|
||||
if (value.trim().length === 0) {
|
||||
this.refreshSuggestionViability();
|
||||
}
|
||||
@@ -1594,6 +1608,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
applyExampleQuery(example: string): void {
|
||||
this.trackSuggestedExecution(example, 'starter');
|
||||
this.recordAmbientAction('search_example', {
|
||||
source: 'global_search_example_chip',
|
||||
queryHint: example,
|
||||
@@ -1604,6 +1619,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
applyQuestionQuery(query: string, source: 'common' | 'answer' | 'clarify'): void {
|
||||
this.trackSuggestedExecution(query, source === 'common' ? 'starter' : 'question');
|
||||
const action = source === 'common'
|
||||
? 'search_common_question'
|
||||
: source === 'clarify'
|
||||
@@ -1619,6 +1635,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
applyAnswerNextSearch(query: string): void {
|
||||
this.trackSuggestedExecution(query, 'answer-next');
|
||||
this.recordAmbientAction('search_answer_next_search', {
|
||||
source: 'global_search_self_serve',
|
||||
queryHint: query,
|
||||
@@ -1629,6 +1646,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
applySuggestion(text: string): void {
|
||||
this.trackSuggestedExecution(text, 'did-you-mean');
|
||||
this.recordAmbientAction('search_suggestion', {
|
||||
source: 'global_search_did_you_mean',
|
||||
queryHint: text,
|
||||
@@ -1952,7 +1970,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
case 'insufficient':
|
||||
return this.t('ui.search.answer.title.insufficient', 'Not enough evidence yet');
|
||||
default:
|
||||
return this.t('ui.search.answer.title.find', 'What we found');
|
||||
return this.t('ui.search.answer.title.find', 'Best answer');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1993,6 +2011,11 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
return match ? match.viable : false;
|
||||
}
|
||||
|
||||
private isStarterQuerySuppressed(query: string): boolean {
|
||||
const normalized = this.normalizeQueryKey(query);
|
||||
return normalized.length > 0 && this.suppressedStarterQueries().includes(normalized);
|
||||
}
|
||||
|
||||
private refreshSuggestionViability(): void {
|
||||
if (!this.isFocused()) {
|
||||
return;
|
||||
@@ -2091,6 +2114,19 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private trackSuggestedExecution(query: string, source: SuggestedExecutionSource): void {
|
||||
const normalized = query.trim();
|
||||
if (!normalized) {
|
||||
this.pendingSuggestedExecution = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingSuggestedExecution = {
|
||||
query: normalized,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveSuggestionReason(suggestion: {
|
||||
reasonKey?: string;
|
||||
reasonFallback?: string;
|
||||
@@ -2195,6 +2231,23 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
|| (response.synthesis?.sourceCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
private reconcileSuggestedExecution(response: UnifiedSearchResponse): void {
|
||||
const pending = this.pendingSuggestedExecution;
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.normalizeQueryKey(response.query) !== this.normalizeQueryKey(pending.query)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.hasSearchEvidence(response)) {
|
||||
this.suppressStarterQuery(response.query);
|
||||
}
|
||||
|
||||
this.pendingSuggestedExecution = null;
|
||||
}
|
||||
|
||||
private persistRecentSearches(entries: readonly SuccessfulSearchHistoryEntry[]): void {
|
||||
this.recentSearches.set(entries.map((entry) => entry.query));
|
||||
try {
|
||||
@@ -2220,6 +2273,28 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
private suppressStarterQuery(query: string): void {
|
||||
const normalized = this.normalizeQueryKey(query);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.suppressedStarterQueries.update((queries) =>
|
||||
queries.includes(normalized)
|
||||
? queries
|
||||
: [...queries, normalized],
|
||||
);
|
||||
}
|
||||
|
||||
private clearSuppressedStarterQueries(): void {
|
||||
this.pendingSuggestedExecution = null;
|
||||
this.suppressedStarterQueries.set([]);
|
||||
}
|
||||
|
||||
private normalizeQueryKey(query: string): string {
|
||||
return query.trim().toLowerCase();
|
||||
}
|
||||
|
||||
private normalizeActionRoute(route: string): string {
|
||||
return normalizeSearchActionRoute(route);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import { ChatComponent } from '../../features/advisory-ai/chat';
|
||||
class="assistant-host__drawer assistant-drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="AdvisoryAI assistant"
|
||||
aria-label="Search assistant"
|
||||
tabindex="-1"
|
||||
>
|
||||
<stellaops-chat
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('ChatMessageComponent (advisory_ai_chat)', () => {
|
||||
it('renders assistant role and grounding score', () => {
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
|
||||
expect(text).toContain('AdvisoryAI');
|
||||
expect(text).toContain('Assistant');
|
||||
expect(text).toContain('88%');
|
||||
});
|
||||
|
||||
|
||||
@@ -97,7 +97,11 @@ describe('ChatComponent (advisory_ai_chat)', () => {
|
||||
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;
|
||||
const heading = fixture.nativeElement.querySelector('.header-title') as HTMLElement | null;
|
||||
const emptyHeading = fixture.nativeElement.querySelector('.empty-state h3') as HTMLElement | null;
|
||||
|
||||
expect(heading?.textContent).toContain('Search assistant');
|
||||
expect(emptyHeading?.textContent).toContain('Go deeper');
|
||||
expect(emptyState?.textContent).toContain('current page');
|
||||
expect(suggestionButtons[0].textContent.trim()).toBe(
|
||||
'Summarize what matters here and what I should do next.',
|
||||
|
||||
@@ -504,7 +504,7 @@ describe('GlobalSearchComponent', () => {
|
||||
|
||||
const answerPanel = fixture.nativeElement.querySelector('[data-answer-status="grounded"]') as HTMLElement | null;
|
||||
expect(answerPanel).not.toBeNull();
|
||||
expect(answerPanel?.textContent).toContain('What we found');
|
||||
expect(answerPanel?.textContent).toContain('Best answer');
|
||||
expect(answerPanel?.textContent).toContain('Current-scope findings matched first, with one related policy hit held as overflow.');
|
||||
expect(answerPanel?.textContent).toContain('Grounded in 2 sources across Findings and Policy.');
|
||||
expect(answerPanel?.textContent).toContain('findings sample');
|
||||
@@ -554,10 +554,61 @@ describe('GlobalSearchComponent', () => {
|
||||
const overflowSection = fixture.nativeElement.querySelector('[data-overflow-results]') as HTMLElement | null;
|
||||
expect(overflowSection).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.search__scope-hint')).toBeNull();
|
||||
expect(overflowSection?.textContent).toContain('Also worth checking');
|
||||
expect(overflowSection?.textContent).toContain('Also relevant elsewhere');
|
||||
expect(overflowSection?.textContent).toContain('Related policy evidence is relevant but secondary to the Doctor page context.');
|
||||
});
|
||||
|
||||
it('suppresses a starter query after it executes with no results', async () => {
|
||||
searchClient.search.and.returnValues(
|
||||
of({
|
||||
query: 'How do I deploy?',
|
||||
topK: 10,
|
||||
cards: [],
|
||||
synthesis: null,
|
||||
diagnostics: {
|
||||
ftsMatches: 0,
|
||||
vectorMatches: 0,
|
||||
entityCardCount: 0,
|
||||
durationMs: 2,
|
||||
usedVector: false,
|
||||
mode: 'fts-only',
|
||||
},
|
||||
}),
|
||||
of({
|
||||
query: '',
|
||||
topK: 10,
|
||||
cards: [],
|
||||
synthesis: null,
|
||||
diagnostics: {
|
||||
ftsMatches: 0,
|
||||
vectorMatches: 0,
|
||||
entityCardCount: 0,
|
||||
durationMs: 0,
|
||||
usedVector: false,
|
||||
mode: 'fts-only',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
component.onFocus();
|
||||
fixture.detectChanges();
|
||||
component.applyStarterQuery({ query: 'How do I deploy?', kind: 'suggestion' });
|
||||
await waitForDebounce();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.recentSearches()).toEqual([]);
|
||||
expect(component.suppressedStarterQueries()).toContain('how do i deploy?');
|
||||
|
||||
component.onQueryChange('');
|
||||
fixture.detectChanges();
|
||||
|
||||
const starterButtons = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('[data-starter-kind]') as NodeListOf<HTMLButtonElement>,
|
||||
).map((node) => node.textContent?.trim());
|
||||
|
||||
expect(starterButtons).not.toContain('How do I deploy?');
|
||||
});
|
||||
|
||||
it('suppresses contextual chips marked non-viable by backend suggestion evaluation', () => {
|
||||
searchClient.evaluateSuggestions.and.returnValue(of({
|
||||
suggestions: [
|
||||
|
||||
@@ -154,6 +154,7 @@ test.describe('Unified Search - Experience Quality UX', () => {
|
||||
await page.locator('.search__chat-launcher').click();
|
||||
|
||||
await expect(page.locator('.assistant-drawer')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator('.header-title')).toContainText('Search assistant');
|
||||
await expect(page.locator('app-global-search input[type="text"]')).toHaveValue('');
|
||||
await expect.poll(() => capturedTurnBodies.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -192,6 +193,54 @@ test.describe('Unified Search - Experience Quality UX', () => {
|
||||
await waitForEntityCards(page, 1);
|
||||
});
|
||||
|
||||
test('suppresses a starter chip after it executes to no results and keeps it out of history', async ({ page }) => {
|
||||
await page.route('**/search/query**', async (route) => {
|
||||
const request = route.request().postDataJSON() as Record<string, unknown>;
|
||||
const query = String(request['q'] ?? '').toLowerCase();
|
||||
const response = query.includes('critical findings')
|
||||
? emptyResponse('critical findings')
|
||||
: criticalFindingResponse;
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/advisory-ai/search/history', async (route) => {
|
||||
if (route.request().method() === 'DELETE') {
|
||||
return route.fulfill({ status: 204, body: '' });
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ entries: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/security/triage');
|
||||
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const searchInput = page.locator('app-global-search input[type="text"]');
|
||||
await searchInput.focus();
|
||||
await waitForResults(page);
|
||||
|
||||
const failingChip = page.locator('[data-starter-kind]', { hasText: /critical findings/i }).first();
|
||||
await expect(failingChip).toBeVisible();
|
||||
|
||||
await failingChip.click();
|
||||
await waitForResults(page);
|
||||
await expect(page.locator('.search__empty')).toContainText('No results found');
|
||||
|
||||
await searchInput.fill('');
|
||||
await waitForResults(page);
|
||||
|
||||
await expect(page.locator('[data-starter-kind]', { hasText: /critical findings/i })).toHaveCount(0);
|
||||
await expect(page.locator('.search__group').filter({ hasText: 'Recent' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('renders did-you-mean directly below the search bar and removes teaching controls', async ({ page }) => {
|
||||
const capturedRequests: Array<Record<string, unknown>> = [];
|
||||
await page.route('**/search/query**', async (route) => {
|
||||
@@ -310,7 +359,7 @@ test.describe('Unified Search - Experience Quality UX', () => {
|
||||
/current-page findings matched first/i,
|
||||
);
|
||||
await expect(page.locator('.search__scope-hint')).toHaveCount(0);
|
||||
await expect(page.locator('[data-overflow-results]')).toContainText(/also worth checking/i);
|
||||
await expect(page.locator('[data-overflow-results]')).toContainText(/also relevant elsewhere/i);
|
||||
await expect(page.locator('[data-overflow-results]')).toContainText(/policy results remain relevant/i);
|
||||
await expect(page.locator('[data-role="domain-filter"]')).toHaveCount(0);
|
||||
await expect(page.locator('app-synthesis-panel')).toHaveCount(0);
|
||||
|
||||
@@ -97,7 +97,7 @@ test.describe('Unified Search - Self-Serve Answer Panel', () => {
|
||||
|
||||
const answerPanel = page.locator('[data-answer-status="grounded"]');
|
||||
await expect(answerPanel).toBeVisible();
|
||||
await expect(answerPanel).toContainText('What we found');
|
||||
await expect(answerPanel).toContainText('Best answer');
|
||||
await expect(answerPanel).toContainText('A reachable critical finding is blocking the current workflow and policy review is warranted.');
|
||||
await expect(answerPanel).toContainText('Grounded in 2 source(s) across Findings, Policy.');
|
||||
await expect(answerPanel.locator('[data-answer-citation]')).toContainText(['CVE-2024-21626 in api-gateway']);
|
||||
@@ -156,6 +156,7 @@ 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('.header-title')).toContainText('Search assistant');
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user