Consolidate search-first shell UX

This commit is contained in:
master
2026-03-08 00:14:57 +02:00
parent f709d519ec
commit a6187c70b4
13 changed files with 222 additions and 41 deletions

View File

@@ -19,7 +19,7 @@
## Delivery Tracker ## Delivery Tracker
### FE-SF-001 - Consolidate shell language around search-first entry ### FE-SF-001 - Consolidate shell language around search-first entry
Status: TODO Status: DONE
Dependency: none Dependency: none
Owners: Developer Owners: Developer
Task description: 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. - Keep the compact assistant icon beside the search field, but use secondary "details/deeper help" language in the primary surface.
Completion criteria: Completion criteria:
- [ ] The top-bar search surface reads as one workflow with a secondary assistant action. - [x] 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. - [x] Answer-panel and launcher labels no longer imply a competing primary entry model.
- [ ] No search-origin assistant action performs a route jump. - [x] No search-origin assistant action performs a route jump.
### FE-SF-002 - Make automatic result shaping clearer than mechanics ### FE-SF-002 - Make automatic result shaping clearer than mechanics
Status: TODO Status: DONE
Dependency: FE-SF-001 Dependency: FE-SF-001
Owners: Developer Owners: Developer
Task description: Task description:
@@ -40,12 +40,12 @@ Task description:
- Keep `Did you mean` directly below the input and avoid any scope or refinement teaching copy. - Keep `Did you mean` directly below the input and avoid any scope or refinement teaching copy.
Completion criteria: Completion criteria:
- [ ] `Did you mean` stays input-adjacent. - [x] `Did you mean` stays input-adjacent.
- [ ] Overflow is visibly secondary and uses plain operator-facing labels. - [x] Overflow is visibly secondary and uses plain operator-facing labels.
- [ ] No recovery/refinement mechanics are rendered in the primary flow. - [x] No recovery/refinement mechanics are rendered in the primary flow.
### FE-SF-003 - Tighten suggestion handling and successful history behavior ### FE-SF-003 - Tighten suggestion handling and successful history behavior
Status: TODO Status: DONE
Dependency: FE-SF-002 Dependency: FE-SF-002
Owners: Developer Owners: Developer
Task description: Task description:
@@ -53,12 +53,12 @@ Task description:
- Keep history success-only and make the clear-history affordance remain low-emphasis. - Keep history success-only and make the clear-history affordance remain low-emphasis.
Completion criteria: Completion criteria:
- [ ] Starter chips disappear when backend viability marks them non-executable. - [x] Starter chips disappear when backend viability marks them non-executable.
- [ ] No-result searches never persist into history. - [x] No-result searches never persist into history.
- [ ] The clear-history action remains a discreet icon treatment. - [x] The clear-history action remains a discreet icon treatment.
### FE-SF-004 - Verify the shell consolidation paths ### FE-SF-004 - Verify the shell consolidation paths
Status: TODO Status: DONE
Dependency: FE-SF-003 Dependency: FE-SF-003
Owners: Developer, Test Automation Owners: Developer, Test Automation
Task description: Task description:
@@ -66,18 +66,20 @@ Task description:
- Cover suggestion execution and no-result history behavior in the FE surface. - Cover suggestion execution and no-result history behavior in the FE surface.
Completion criteria: Completion criteria:
- [ ] Angular tests cover updated labels, answer/overflow presentation, and success-only history behavior. - [x] 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. - [x] 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] No visible mode or scope controls appear in covered flows.
## Execution Log ## Execution Log
| Date (UTC) | Update | Owner | | 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 | 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 ## Decisions & Risks
- Decision: assistant remains available but secondary; search is the first-class workflow. - 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: 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. - 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. - Mitigation: change the primary-surface labels and add focused FE regression coverage.
- Reference: `docs/modules/ui/search-zero-learning-primary-entry.md` - Reference: `docs/modules/ui/search-zero-learning-primary-entry.md`

View File

@@ -9,8 +9,6 @@
- `docs/implplan/SPRINT_20260307_009_DOCS_ui_component_preservation_map.md` - `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_022_FE_policy_vex_release_decisioning_studio.md`
- `docs/implplan/SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.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` - `docs/implplan/SPRINT_20260307_038_FE_live_search_supported_route_execution_matrix.md`
## Delivery Tasks ## Delivery Tasks
@@ -50,10 +48,10 @@
- [DONE] FE-UX-E2E Playwright coverage for mode switching, rescue flows, and AdvisoryAI next-step cards - [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) - [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) - [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 - [DONE] FE-SF-001 Search-first shell language consolidation
- [TODO] FE-SF-002 Automatic answer/overflow presentation cleanup - [DONE] FE-SF-002 Automatic answer/overflow presentation cleanup
- [TODO] FE-SF-003 Suggestion execution and success-only history hardening - [DONE] FE-SF-003 Suggestion execution and success-only history hardening
- [TODO] FE-SF-004 Search-first shell verification coverage - [DONE] FE-SF-004 Search-first shell verification coverage
- [TODO] QA-SF-001 Live route preflight and corpus readiness gate - [TODO] QA-SF-001 Live route preflight and corpus readiness gate
- [TODO] QA-SF-002 Execute surfaced suggestions on supported routes - [TODO] QA-SF-002 Execute surfaced suggestions on supported routes
- [TODO] QA-SF-003 Align deterministic and live search-first matrices - [TODO] QA-SF-003 Align deterministic and live search-first matrices

View File

@@ -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 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 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 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. - 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 ## Execution phases - operator correction pass

View File

@@ -79,7 +79,7 @@ interface NextStepCard {
<!-- Message content --> <!-- Message content -->
<div class="message-content"> <div class="message-content">
<header class="message-header"> <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"> <time class="message-time" [attr.datetime]="turn.timestamp">
{{ formatTime(turn.timestamp) }} {{ formatTime(turn.timestamp) }}
</time> </time>

View File

@@ -55,7 +55,7 @@ import {
<path d="M16 15.5v.01"/> <path d="M16 15.5v.01"/>
<path d="M12 12v.01"/> <path d="M12 12v.01"/>
</svg> </svg>
<h2 class="header-title">AdvisoryAI</h2> <h2 class="header-title">Search assistant</h2>
@if (conversation()) { @if (conversation()) {
<span class="conversation-id">{{ conversation()!.conversationId.substring(0, 8) }}</span> <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"> <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"/> <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> </svg>
<h3>Ask AdvisoryAI</h3> <h3>Go deeper</h3>
<p>{{ emptyStateDescription() }}</p> <p>{{ emptyStateDescription() }}</p>
<div class="suggestions"> <div class="suggestions">
@for (suggestion of suggestions(); track suggestion) { @for (suggestion of suggestions(); track suggestion) {
@@ -144,7 +144,7 @@ import {
</div> </div>
<div class="message-content"> <div class="message-content">
<header class="message-header"> <header class="message-header">
<span class="message-role">AdvisoryAI</span> <span class="message-role">Assistant</span>
<span class="typing-indicator"> <span class="typing-indicator">
@if (progressStage()) { @if (progressStage()) {
{{ progressStage() }} {{ progressStage() }}

View File

@@ -49,6 +49,7 @@ type SearchStarterView = {
query: string; query: string;
kind: 'question' | 'suggestion'; kind: 'question' | 'suggestion';
}; };
type SuggestedExecutionSource = 'starter' | 'question' | 'answer-next' | 'did-you-mean';
type SearchContextPanelView = { type SearchContextPanelView = {
title: string; title: string;
description: string; description: string;
@@ -106,8 +107,8 @@ type SuccessfulSearchHistoryEntry = {
<button <button
type="button" type="button"
class="search__chat-launcher" class="search__chat-launcher"
[attr.aria-label]="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 AdvisoryAI chat')" [title]="t('ui.search.chat_launcher', 'Open deeper help')"
(mousedown)="$event.preventDefault()" (mousedown)="$event.preventDefault()"
(click)="openAssistantFromSearchBar()" (click)="openAssistantFromSearchBar()"
> >
@@ -162,7 +163,7 @@ type SuccessfulSearchHistoryEntry = {
data-answer-action="ask-ai" data-answer-action="ask-ai"
(click)="openAssistantForAnswerPanel()" (click)="openAssistantForAnswerPanel()"
> >
{{ t('ui.search.answer.ask_ai', 'Ask AdvisoryAI') }} {{ t('ui.search.answer.ask_ai', 'Open details') }}
</button> </button>
</div> </div>
<div class="search__answer-evidence">{{ answer.evidence }}</div> <div class="search__answer-evidence">{{ answer.evidence }}</div>
@@ -328,7 +329,7 @@ type SuccessfulSearchHistoryEntry = {
@if (starterQueries().length > 0) { @if (starterQueries().length > 0) {
<div class="search__suggestions"> <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"> <div class="search__starter-chips">
@for (starter of starterQueries(); track starter.query) { @for (starter of starterQueries(); track starter.query) {
<button <button
@@ -927,6 +928,9 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
private readonly recentSearchStorageKey = 'stella-successful-searches-v3'; private readonly recentSearchStorageKey = 'stella-successful-searches-v3';
private readonly legacySuccessfulSearchStorageKey = 'stella-successful-searches-v2'; private readonly legacySuccessfulSearchStorageKey = 'stella-successful-searches-v2';
private readonly legacyRecentSearchStorageKey = 'stella-recent-searches'; private readonly legacyRecentSearchStorageKey = 'stella-recent-searches';
private pendingSuggestedExecution:
| { query: string; source: SuggestedExecutionSource }
| null = null;
private wasDegradedMode = false; private wasDegradedMode = false;
private escapeCount = 0; private escapeCount = 0;
private placeholderRotationHandle: ReturnType<typeof setInterval> | null = null; private placeholderRotationHandle: ReturnType<typeof setInterval> | null = null;
@@ -947,6 +951,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
readonly searchResponse = signal<UnifiedSearchResponse | null>(null); readonly searchResponse = signal<UnifiedSearchResponse | null>(null);
readonly suggestionViability = signal<SearchSuggestionViabilityResponse | null>(null); readonly suggestionViability = signal<SearchSuggestionViabilityResponse | null>(null);
readonly recentSearches = signal<string[]>([]); readonly recentSearches = signal<string[]>([]);
readonly suppressedStarterQueries = signal<string[]>([]);
readonly expandedCardKey = signal<string | null>(null); readonly expandedCardKey = signal<string | null>(null);
readonly showResults = computed(() => readonly showResults = computed(() =>
@@ -1092,7 +1097,10 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
readonly contextualSuggestions = computed<SearchSuggestionView[]>(() => { readonly contextualSuggestions = computed<SearchSuggestionView[]>(() => {
return [...this.ambientContext.getSearchSuggestions()] return [...this.ambientContext.getSearchSuggestions()]
.sort((left, right) => this.scoreSuggestion(right) - this.scoreSuggestion(left)) .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) => ({ .map((suggestion) => ({
query: this.i18n.tryT(suggestion.key) ?? suggestion.fallback, query: this.i18n.tryT(suggestion.key) ?? suggestion.fallback,
reason: this.resolveSuggestionReason(suggestion), reason: this.resolveSuggestionReason(suggestion),
@@ -1103,7 +1111,10 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
readonly commonQuestions = computed<SearchQuestionView[]>(() => { readonly commonQuestions = computed<SearchQuestionView[]>(() => {
return [...this.ambientContext.getCommonQuestions()] return [...this.ambientContext.getCommonQuestions()]
.sort((left, right) => this.scoreQuestion(right) - this.scoreQuestion(left)) .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) => ({ .map((question) => ({
query: this.i18n.tryT(question.key) ?? question.fallback, query: this.i18n.tryT(question.key) ?? question.fallback,
kind: question.kind ?? 'page', kind: question.kind ?? 'page',
@@ -1165,7 +1176,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
return { return {
status: 'grounded', status: 'grounded',
eyebrow, 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), summary: this.buildGroundedAnswerSummary(response),
evidence: this.buildGroundedEvidenceLabel(response), evidence: this.buildGroundedEvidenceLabel(response),
citations: this.buildAnswerCitations(response), citations: this.buildAnswerCitations(response),
@@ -1269,7 +1280,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
})); }));
}); });
readonly overflowSectionTitle = computed(() => readonly overflowSectionTitle = computed(() =>
this.t('ui.search.results.overflow', 'Also worth checking')); this.t('ui.search.results.overflow', 'Also relevant elsewhere'));
readonly overflowSectionReason = computed(() => readonly overflowSectionReason = computed(() =>
this.searchResponse()?.overflow?.reason this.searchResponse()?.overflow?.reason
?? this.t( ?? this.t(
@@ -1295,6 +1306,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
) )
.subscribe(() => { .subscribe(() => {
this.consumeChatToSearchContext(); this.consumeChatToSearchContext();
this.clearSuppressedStarterQueries();
if (this.isFocused()) { if (this.isFocused()) {
this.refreshSuggestionViability(); this.refreshSuggestionViability();
} }
@@ -1355,6 +1367,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
Math.max(1, response.cards.length + overflowCount), Math.max(1, response.cards.length + overflowCount),
); );
} }
this.reconcileSuggestedExecution(response);
// Sprint 106 / G6: Emit search analytics events // Sprint 106 / G6: Emit search analytics events
this.emitSearchAnalytics(response); this.emitSearchAnalytics(response);
@@ -1415,6 +1428,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
this.query.set(value); this.query.set(value);
this.selectedIndex.set(0); this.selectedIndex.set(0);
this.escapeCount = 0; this.escapeCount = 0;
this.pendingSuggestedExecution = null;
if (value.trim().length === 0) { if (value.trim().length === 0) {
this.refreshSuggestionViability(); this.refreshSuggestionViability();
} }
@@ -1594,6 +1608,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
} }
applyExampleQuery(example: string): void { applyExampleQuery(example: string): void {
this.trackSuggestedExecution(example, 'starter');
this.recordAmbientAction('search_example', { this.recordAmbientAction('search_example', {
source: 'global_search_example_chip', source: 'global_search_example_chip',
queryHint: example, queryHint: example,
@@ -1604,6 +1619,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
} }
applyQuestionQuery(query: string, source: 'common' | 'answer' | 'clarify'): void { applyQuestionQuery(query: string, source: 'common' | 'answer' | 'clarify'): void {
this.trackSuggestedExecution(query, source === 'common' ? 'starter' : 'question');
const action = source === 'common' const action = source === 'common'
? 'search_common_question' ? 'search_common_question'
: source === 'clarify' : source === 'clarify'
@@ -1619,6 +1635,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
} }
applyAnswerNextSearch(query: string): void { applyAnswerNextSearch(query: string): void {
this.trackSuggestedExecution(query, 'answer-next');
this.recordAmbientAction('search_answer_next_search', { this.recordAmbientAction('search_answer_next_search', {
source: 'global_search_self_serve', source: 'global_search_self_serve',
queryHint: query, queryHint: query,
@@ -1629,6 +1646,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
} }
applySuggestion(text: string): void { applySuggestion(text: string): void {
this.trackSuggestedExecution(text, 'did-you-mean');
this.recordAmbientAction('search_suggestion', { this.recordAmbientAction('search_suggestion', {
source: 'global_search_did_you_mean', source: 'global_search_did_you_mean',
queryHint: text, queryHint: text,
@@ -1952,7 +1970,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
case 'insufficient': case 'insufficient':
return this.t('ui.search.answer.title.insufficient', 'Not enough evidence yet'); return this.t('ui.search.answer.title.insufficient', 'Not enough evidence yet');
default: 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; 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 { private refreshSuggestionViability(): void {
if (!this.isFocused()) { if (!this.isFocused()) {
return; 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: { private resolveSuggestionReason(suggestion: {
reasonKey?: string; reasonKey?: string;
reasonFallback?: string; reasonFallback?: string;
@@ -2195,6 +2231,23 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|| (response.synthesis?.sourceCount ?? 0) > 0; || (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 { private persistRecentSearches(entries: readonly SuccessfulSearchHistoryEntry[]): void {
this.recentSearches.set(entries.map((entry) => entry.query)); this.recentSearches.set(entries.map((entry) => entry.query));
try { try {
@@ -2220,6 +2273,28 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
.slice(0, 10); .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 { private normalizeActionRoute(route: string): string {
return normalizeSearchActionRoute(route); return normalizeSearchActionRoute(route);
} }

View File

@@ -28,7 +28,7 @@ import { ChatComponent } from '../../features/advisory-ai/chat';
class="assistant-host__drawer assistant-drawer" class="assistant-host__drawer assistant-drawer"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="AdvisoryAI assistant" aria-label="Search assistant"
tabindex="-1" tabindex="-1"
> >
<stellaops-chat <stellaops-chat

View File

@@ -46,7 +46,7 @@ describe('ChatMessageComponent (advisory_ai_chat)', () => {
it('renders assistant role and grounding score', () => { it('renders assistant role and grounding score', () => {
const text = fixture.nativeElement.textContent as string; const text = fixture.nativeElement.textContent as string;
expect(text).toContain('AdvisoryAI'); expect(text).toContain('Assistant');
expect(text).toContain('88%'); expect(text).toContain('88%');
}); });

View File

@@ -97,7 +97,11 @@ describe('ChatComponent (advisory_ai_chat)', () => {
const suggestionButtons = fixture.nativeElement.querySelectorAll('.suggestion-btn'); const suggestionButtons = fixture.nativeElement.querySelectorAll('.suggestion-btn');
const textarea = fixture.nativeElement.querySelector('.chat-input') as HTMLTextAreaElement | null; const textarea = fixture.nativeElement.querySelector('.chat-input') as HTMLTextAreaElement | null;
const emptyState = fixture.nativeElement.querySelector('.empty-state p') as HTMLElement | 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(emptyState?.textContent).toContain('current page');
expect(suggestionButtons[0].textContent.trim()).toBe( expect(suggestionButtons[0].textContent.trim()).toBe(
'Summarize what matters here and what I should do next.', 'Summarize what matters here and what I should do next.',

View File

@@ -504,7 +504,7 @@ describe('GlobalSearchComponent', () => {
const answerPanel = fixture.nativeElement.querySelector('[data-answer-status="grounded"]') as HTMLElement | null; const answerPanel = fixture.nativeElement.querySelector('[data-answer-status="grounded"]') as HTMLElement | null;
expect(answerPanel).not.toBeNull(); 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('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('Grounded in 2 sources across Findings and Policy.');
expect(answerPanel?.textContent).toContain('findings sample'); expect(answerPanel?.textContent).toContain('findings sample');
@@ -554,10 +554,61 @@ describe('GlobalSearchComponent', () => {
const overflowSection = fixture.nativeElement.querySelector('[data-overflow-results]') as HTMLElement | null; const overflowSection = fixture.nativeElement.querySelector('[data-overflow-results]') as HTMLElement | null;
expect(overflowSection).not.toBeNull(); expect(overflowSection).not.toBeNull();
expect(fixture.nativeElement.querySelector('.search__scope-hint')).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.'); 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', () => { it('suppresses contextual chips marked non-viable by backend suggestion evaluation', () => {
searchClient.evaluateSuggestions.and.returnValue(of({ searchClient.evaluateSuggestions.and.returnValue(of({
suggestions: [ suggestions: [

View File

@@ -154,6 +154,7 @@ test.describe('Unified Search - Experience Quality UX', () => {
await page.locator('.search__chat-launcher').click(); await page.locator('.search__chat-launcher').click();
await expect(page.locator('.assistant-drawer')).toBeVisible({ timeout: 10_000 }); 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(page.locator('app-global-search input[type="text"]')).toHaveValue('');
await expect.poll(() => capturedTurnBodies.length).toBeGreaterThan(0); await expect.poll(() => capturedTurnBodies.length).toBeGreaterThan(0);
@@ -192,6 +193,54 @@ test.describe('Unified Search - Experience Quality UX', () => {
await waitForEntityCards(page, 1); 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 }) => { test('renders did-you-mean directly below the search bar and removes teaching controls', async ({ page }) => {
const capturedRequests: Array<Record<string, unknown>> = []; const capturedRequests: Array<Record<string, unknown>> = [];
await page.route('**/search/query**', async (route) => { await page.route('**/search/query**', async (route) => {
@@ -310,7 +359,7 @@ test.describe('Unified Search - Experience Quality UX', () => {
/current-page findings matched first/i, /current-page findings matched first/i,
); );
await expect(page.locator('.search__scope-hint')).toHaveCount(0); 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-overflow-results]')).toContainText(/policy results remain relevant/i);
await expect(page.locator('[data-role="domain-filter"]')).toHaveCount(0); await expect(page.locator('[data-role="domain-filter"]')).toHaveCount(0);
await expect(page.locator('app-synthesis-panel')).toHaveCount(0); await expect(page.locator('app-synthesis-panel')).toHaveCount(0);

View File

@@ -97,7 +97,7 @@ test.describe('Unified Search - Self-Serve Answer Panel', () => {
const answerPanel = page.locator('[data-answer-status="grounded"]'); const answerPanel = page.locator('[data-answer-status="grounded"]');
await expect(answerPanel).toBeVisible(); 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('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).toContainText('Grounded in 2 source(s) across Findings, Policy.');
await expect(answerPanel.locator('[data-answer-citation]')).toContainText(['CVE-2024-21626 in api-gateway']); 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 page.locator('[data-answer-action="ask-ai"]').click();
await expect(page.locator('.assistant-drawer')).toBeVisible({ timeout: 10_000 }); 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(/Expand the grounded answer/i);
await expect(page.locator('.chat-message.user .message-body').first()).toContainText(/critical findings/i); await expect(page.locator('.chat-message.user .message-body').first()).toContainText(/critical findings/i);
await expect.poll(() => capturedTurnBodies.length).toBeGreaterThan(0); await expect.poll(() => capturedTurnBodies.length).toBeGreaterThan(0);