Collapse search into zero-learning starters
This commit is contained in:
@@ -17,7 +17,7 @@
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-SC-101 - Collapse empty-state education
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: Developer
|
||||
Task description:
|
||||
@@ -25,12 +25,12 @@ Task description:
|
||||
- Replace them with a small set of viable, page-aware search starts only when useful.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Empty-state UI does not present domain cards or "learn Stella" quick links as the main action.
|
||||
- [ ] Suggestions shown in the empty state remain executable and page-aware.
|
||||
- [ ] Search history remains successful-only and is visually low-emphasis.
|
||||
- [x] Empty-state UI does not present domain cards or "learn Stella" quick links as the main action.
|
||||
- [x] Suggestions shown in the empty state remain executable and page-aware.
|
||||
- [x] Search history remains successful-only and is visually low-emphasis.
|
||||
|
||||
### FE-SC-102 - Simplify in-result cues
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: FE-SC-101
|
||||
Owners: Developer
|
||||
Task description:
|
||||
@@ -38,12 +38,12 @@ Task description:
|
||||
- Stop explaining scope weighting mechanics in the main flow; show the better in-scope answer first, then overflow only when needed.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] `Did you mean` is visually attached to the input.
|
||||
- [ ] Scope weighting hints are removed or translated into plain operator-facing result labels.
|
||||
- [ ] Overflow only appears when present and is visually secondary to the best in-scope answer.
|
||||
- [x] `Did you mean` is visually attached to the input.
|
||||
- [x] Scope weighting hints are removed or translated into plain operator-facing result labels.
|
||||
- [x] Overflow only appears when present and is visually secondary to the best in-scope answer.
|
||||
|
||||
### FE-SC-103 - Harden suggestion and history behavior
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: FE-SC-102
|
||||
Owners: Developer, Test Automation
|
||||
Task description:
|
||||
@@ -51,14 +51,15 @@ Task description:
|
||||
- Exercise user flows that previously felt broken from the empty state.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] No-result queries do not reappear in rendered history.
|
||||
- [ ] Suggestion clicks from the empty state remain non-dead-end flows.
|
||||
- [ ] Playwright covers history, suggestions, did-you-mean placement, and overflow presentation.
|
||||
- [x] No-result queries do not reappear in rendered history.
|
||||
- [x] Suggestion clicks from the empty state remain non-dead-end flows.
|
||||
- [x] Playwright covers history, suggestions, did-you-mean placement, and overflow presentation.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-07 | Sprint created from operator feedback that the search surface still teaches too much of Stella instead of simply helping. | Project Manager |
|
||||
| 2026-03-07 | Removed domain-guide and quick-link empty-state panels, collapsed starters into a single executable list, kept only recent successful history plus page context, and verified with targeted Angular tests and the full search Playwright pack including live Doctor ingestion. | Developer |
|
||||
|
||||
## Decisions & Risks
|
||||
- Decision: the empty state should help the operator start, not explain Stella's information architecture.
|
||||
|
||||
@@ -96,6 +96,8 @@
|
||||
- Fail fast when corpus rebuild/readiness is missing so dead suggestions are treated as setup failures, not flaky UI tests.
|
||||
|
||||
## Current state
|
||||
- Implemented before the corrective phases: explicit scope/mode/recovery controls were removed from the main search flow, implicit current-scope weighting and overflow contracts were added, and suggestion viability preflight now suppresses dead chips before render.
|
||||
- Implemented from the corrective phases: search now opens AdvisoryAI through a shell-level drawer with no route jump, restores focus back to search when the drawer closes, and removes visible assistant mode framing.
|
||||
- Implemented from the corrective phases: the empty state no longer teaches Stella taxonomy through domain guides or quick links; it now keeps only current-page context, successful recent searches, and executable starter chips.
|
||||
- Implemented before and during the corrective phases: explicit scope/mode/recovery controls were removed from the main search flow, implicit current-scope weighting and overflow contracts were added, and suggestion viability preflight now suppresses dead chips before render.
|
||||
- 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.
|
||||
- Still pending from the corrective phases: shell-level assistant unification, assistant de-mode-ing, empty-state collapse, broader live-page matrices, and explicit client-side telemetry opt-out.
|
||||
- Still pending from the corrective phases: broader live-page matrices, stronger backend ranking/blending refinement across more domains, and explicit client-side telemetry opt-out.
|
||||
|
||||
@@ -45,6 +45,10 @@ type SearchQuestionView = {
|
||||
query: string;
|
||||
kind: 'page' | 'clarify' | 'recent';
|
||||
};
|
||||
type SearchStarterView = {
|
||||
query: string;
|
||||
kind: 'question' | 'suggestion';
|
||||
};
|
||||
type SearchContextPanelView = {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -211,14 +215,10 @@ type SearchAnswerView = {
|
||||
} @else if (query().trim().length >= 1 && visibleCards().length === 0) {
|
||||
<div class="search__empty">{{ t('ui.search.no_results', 'No results found') }}</div>
|
||||
} @else if (query().trim().length >= 1) {
|
||||
@if (scopeWeightingHint(); as scopeHint) {
|
||||
<div class="search__scope-hint">{{ scopeHint }}</div>
|
||||
}
|
||||
|
||||
@if (cards().length > 0) {
|
||||
<div class="search__cards-section">
|
||||
@if (overflowCards().length > 0) {
|
||||
<div class="search__group-label">{{ t('ui.search.results.primary', 'Best match for this page') }}</div>
|
||||
<div class="search__group-label">{{ t('ui.search.results.primary', 'Best match here') }}</div>
|
||||
}
|
||||
<div class="search__cards" role="list" [attr.aria-live]="'polite'">
|
||||
@for (card of cards(); track card.entityKey; let i = $index) {
|
||||
@@ -269,7 +269,7 @@ type SearchAnswerView = {
|
||||
}
|
||||
} @else {
|
||||
@if (recentSearches().length > 0) {
|
||||
<div class="search__group">
|
||||
<div class="search__group search__group--recent">
|
||||
<div class="search__group-header">
|
||||
<div class="search__group-label">{{ t('ui.search.recent_label', 'Recent') }}</div>
|
||||
<button
|
||||
@@ -306,105 +306,45 @@ type SearchAnswerView = {
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (searchContextPanel(); as contextPanel) {
|
||||
<div class="search__context-rail">
|
||||
<div class="search__group-label">{{ t('ui.search.context.label', 'Context') }}</div>
|
||||
<div class="search__context-card">
|
||||
<div class="search__context-title">{{ contextPanel.title }}</div>
|
||||
<div class="search__context-description">{{ contextPanel.description }}</div>
|
||||
@if (starterContextTitle(); as contextTitle) {
|
||||
<div class="search__starter-context">
|
||||
<div class="search__context-title">{{ contextTitle }}</div>
|
||||
@if (starterContextTokens().length > 0) {
|
||||
<div class="search__context-tokens">
|
||||
@for (token of contextPanel.tokens; track token.key) {
|
||||
@for (token of starterContextTokens(); track token.key) {
|
||||
<span class="search__context-token">
|
||||
<span class="search__context-token-label">{{ token.label }}:</span>
|
||||
<span class="search__context-token-value"> {{ token.value }}</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (commonQuestions().length > 0) {
|
||||
<div class="search__questions">
|
||||
<div class="search__group-label">{{ t('ui.search.questions.label', 'Common questions') }}</div>
|
||||
<div class="search__question-chips">
|
||||
@for (question of commonQuestions(); track question.query) {
|
||||
@if (starterQueries().length > 0) {
|
||||
<div class="search__suggestions">
|
||||
<div class="search__group-label">{{ t('ui.search.starters.label', 'Try asking') }}</div>
|
||||
<div class="search__starter-chips">
|
||||
@for (starter of starterQueries(); track starter.query) {
|
||||
<button
|
||||
type="button"
|
||||
class="search__question-chip"
|
||||
[attr.data-common-question]="question.kind"
|
||||
(click)="applyQuestionQuery(question.query, 'common')"
|
||||
class="search__question-chip search__starter-chip search__chip"
|
||||
[attr.data-starter-kind]="starter.kind"
|
||||
(click)="applyStarterQuery(starter)"
|
||||
>
|
||||
{{ question.query }}
|
||||
{{ starter.query }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="search__suggestions">
|
||||
<div class="search__group-label">{{ t('ui.search.suggested_label', 'Suggested') }}</div>
|
||||
<div class="search__suggestion-cards">
|
||||
@for (suggestion of contextualSuggestions(); track suggestion.query) {
|
||||
<div
|
||||
class="search__suggestion-card"
|
||||
[class.search__suggestion-card--recent]="suggestion.kind === 'recent'"
|
||||
[class.search__suggestion-card--strategy]="suggestion.kind === 'strategy'"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="search__chip search__chip--contextual"
|
||||
(click)="applyExampleQuery(suggestion.query)"
|
||||
>{{ suggestion.query }}</button>
|
||||
<div class="search__suggestion-reason">{{ suggestion.reason }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (recentSearches().length === 0 && starterQueries().length === 0) {
|
||||
<div class="search__empty-state-copy">
|
||||
{{ t('ui.search.empty_prompt', 'Ask about what is on this page.') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search__domain-guide">
|
||||
<div class="search__group-label">{{ t('ui.search.empty_state_header', 'Search across your release control plane') }}</div>
|
||||
<div class="search__domain-grid">
|
||||
@for (domain of domainGuide(); track domain.key) {
|
||||
<div class="search__domain-card">
|
||||
<div class="search__domain-title">
|
||||
<span class="search__domain-icon" aria-hidden="true">{{ domain.icon }}</span>
|
||||
{{ domain.title }}
|
||||
</div>
|
||||
<div class="search__domain-desc">{{ domain.description }}</div>
|
||||
<button
|
||||
type="button"
|
||||
class="search__chip search__chip--example"
|
||||
(click)="applyExampleQuery(domain.example)"
|
||||
>{{ domain.example }}</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search__quick-actions">
|
||||
<button type="button" class="search__quick-link" (click)="navigateQuickAction('/welcome')">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
|
||||
</svg>
|
||||
{{ t('ui.search.quick_action.getting_started', 'Getting Started') }}
|
||||
</button>
|
||||
<button type="button" class="search__quick-link" (click)="navigateQuickAction('/ops/operations/doctor')">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
|
||||
</svg>
|
||||
{{ t('ui.search.quick_action.run_health_check', 'Run Health Check') }}
|
||||
</button>
|
||||
<button type="button" class="search__quick-link" (click)="navigateQuickAction('/security/triage')">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 3h18v4H3z"/>
|
||||
<path d="M5 11h14"/>
|
||||
<path d="M5 16h10"/>
|
||||
</svg>
|
||||
{{ t('ui.search.quick_action.view_recent_scans', 'View Recent Scans') }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -560,12 +500,6 @@ type SearchAnswerView = {
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.search__scope-hint {
|
||||
padding: 0.5rem 0.75rem 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.search__cards-section {
|
||||
padding: 0.25rem 0 0.5rem;
|
||||
}
|
||||
@@ -598,6 +532,10 @@ type SearchAnswerView = {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.search__group--recent {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.search__group-label {
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.6875rem;
|
||||
@@ -661,17 +599,8 @@ type SearchAnswerView = {
|
||||
background: var(--color-nav-hover);
|
||||
}
|
||||
|
||||
.search__context-rail {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.search__context-card {
|
||||
margin: 0.25rem 0.75rem 0;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, var(--color-surface-primary) 0%, var(--color-surface-tertiary) 100%);
|
||||
.search__starter-context {
|
||||
padding: 0.75rem 0.75rem 0.25rem;
|
||||
}
|
||||
|
||||
.search__context-title {
|
||||
@@ -680,13 +609,6 @@ type SearchAnswerView = {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.search__context-description {
|
||||
margin-top: 0.1875rem;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.35;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.search__context-tokens {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -868,15 +790,14 @@ type SearchAnswerView = {
|
||||
}
|
||||
|
||||
.search__suggestions {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
padding: 0.5rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.search__suggestion-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
.search__starter-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
padding: 0.25rem 0.75rem 0;
|
||||
}
|
||||
|
||||
.search__chip {
|
||||
@@ -898,138 +819,42 @@ type SearchAnswerView = {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.search__suggestion-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
.search__starter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border: 1px solid var(--color-border-secondary);
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
|
||||
.search__suggestion-card--recent {
|
||||
border-color: var(--color-brand-primary-20, #bfdbfe);
|
||||
background: linear-gradient(180deg, var(--color-brand-primary-10, #eff6ff) 0%, var(--color-surface-primary) 100%);
|
||||
}
|
||||
|
||||
.search__suggestion-card--strategy {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.search__chip--contextual {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.25;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.search__suggestion-reason {
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.35;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.search__chip--example {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-brand-primary, #1e3a8a);
|
||||
.search__starter-chip[data-starter-kind='question'] {
|
||||
border-color: var(--color-brand-primary-20, #bfdbfe);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.search__chip--example:hover {
|
||||
background: var(--color-brand-primary-10, #eff6ff);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.search__domain-guide {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
.search__starter-chip:hover {
|
||||
border-color: var(--color-brand-primary, #1d4ed8);
|
||||
background: var(--color-brand-primary-10, #eff6ff);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.search__domain-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
.search__empty-state-copy {
|
||||
padding: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.search__answer-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search__suggestion-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search__domain-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.search__domain-card {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
|
||||
.search__domain-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.search__domain-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-tertiary);
|
||||
font-size: 0.675rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.search__domain-desc {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.375rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.search__quick-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.search__quick-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-brand-primary, #1e3a8a);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
.search__quick-link:hover {
|
||||
background: var(--color-nav-hover);
|
||||
}
|
||||
|
||||
.did-you-mean {
|
||||
@@ -1076,10 +901,6 @@ type SearchAnswerView = {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.search__quick-link {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.search__question-chip,
|
||||
.search__answer-assistant,
|
||||
.search__answer-next-search {
|
||||
@@ -1404,32 +1225,42 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
readonly visibleCards = computed(() => [...this.cards(), ...this.overflowCards()]);
|
||||
readonly synthesis = computed(() => this.searchResponse()?.synthesis ?? null);
|
||||
readonly filteredCards = computed(() => this.visibleCards());
|
||||
readonly scopeWeightingHint = computed(() => {
|
||||
const coverage = this.searchResponse()?.coverage;
|
||||
if (!coverage?.currentScopeWeighted || !coverage.currentScopeDomain) {
|
||||
return null;
|
||||
}
|
||||
readonly starterContextTitle = computed(() => this.searchContextPanel()?.title ?? null);
|
||||
readonly starterContextTokens = computed(() =>
|
||||
(this.searchContextPanel()?.tokens ?? []).filter((token) => token.key === 'last-action'));
|
||||
readonly starterQueries = computed<SearchStarterView[]>(() => {
|
||||
const starters = [
|
||||
...this.commonQuestions().map((question) => ({
|
||||
query: question.query,
|
||||
kind: 'question' as const,
|
||||
score: this.scoreQuestion(question) + 1,
|
||||
})),
|
||||
...this.contextualSuggestions().map((suggestion) => ({
|
||||
query: suggestion.query,
|
||||
kind: 'suggestion' as const,
|
||||
score: this.scoreSuggestion(suggestion),
|
||||
})),
|
||||
].sort((left, right) => right.score - left.score);
|
||||
|
||||
const domainLabel = DOMAIN_LABELS[coverage.currentScopeDomain as UnifiedSearchDomain]
|
||||
?? coverage.currentScopeDomain;
|
||||
return this.t(
|
||||
'ui.search.scope_weighting',
|
||||
'Weighted toward {domain} because of the current page.',
|
||||
{ domain: domainLabel },
|
||||
);
|
||||
});
|
||||
readonly overflowSectionTitle = computed(() => {
|
||||
const scopeDomain = this.searchResponse()?.overflow?.currentScopeDomain
|
||||
|| this.searchResponse()?.coverage?.currentScopeDomain;
|
||||
const scopeLabel = scopeDomain
|
||||
? (DOMAIN_LABELS[scopeDomain as UnifiedSearchDomain] ?? scopeDomain)
|
||||
: this.t('ui.search.scope.default', 'this page');
|
||||
return this.t(
|
||||
'ui.search.results.overflow',
|
||||
'Also relevant outside {scope}',
|
||||
{ scope: scopeLabel },
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
return starters
|
||||
.filter((starter) => {
|
||||
const normalized = starter.query.trim().toLowerCase();
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(normalized);
|
||||
return true;
|
||||
})
|
||||
.slice(0, 6)
|
||||
.map((starter) => ({
|
||||
query: starter.query,
|
||||
kind: starter.kind,
|
||||
}));
|
||||
});
|
||||
readonly overflowSectionTitle = computed(() =>
|
||||
this.t('ui.search.results.overflow', 'Also relevant elsewhere'));
|
||||
readonly overflowSectionReason = computed(() =>
|
||||
this.searchResponse()?.overflow?.reason
|
||||
?? this.t(
|
||||
@@ -1738,6 +1569,15 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
this.keepSearchSurfaceOpen();
|
||||
}
|
||||
|
||||
applyStarterQuery(starter: SearchStarterView): void {
|
||||
if (starter.kind === 'question') {
|
||||
this.applyQuestionQuery(starter.query, 'common');
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyExampleQuery(starter.query);
|
||||
}
|
||||
|
||||
applyExampleQuery(example: string): void {
|
||||
this.recordAmbientAction('search_example', {
|
||||
source: 'global_search_example_chip',
|
||||
|
||||
@@ -195,41 +195,21 @@ describe('GlobalSearchComponent', () => {
|
||||
expect(placeholder).not.toContain('{suggestion}');
|
||||
});
|
||||
|
||||
it('renders eight domain cards in empty state guide', () => {
|
||||
component.onFocus();
|
||||
fixture.detectChanges();
|
||||
|
||||
const cards = fixture.nativeElement.querySelectorAll('.search__domain-card');
|
||||
expect(cards.length).toBe(8);
|
||||
});
|
||||
|
||||
it('renders the context rail and suggestion rationale in empty state', () => {
|
||||
it('collapses the empty state into current-page starters without product teaching panels', () => {
|
||||
component.onFocus();
|
||||
fixture.detectChanges();
|
||||
|
||||
const contextTitle = fixture.nativeElement.querySelector('.search__context-title') as HTMLElement | null;
|
||||
const contextTokens = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.search__context-token') as NodeListOf<Element>,
|
||||
).map((node) => node.textContent?.replace(/\s+/g, ' ').trim());
|
||||
const suggestionReason = fixture.nativeElement.querySelector('.search__suggestion-reason') as HTMLElement | null;
|
||||
|
||||
expect(contextTitle?.textContent?.trim()).toBe('Findings triage');
|
||||
expect(contextTokens).toContain('Page: Findings triage');
|
||||
expect(contextTokens).toContain('Scope: Findings');
|
||||
expect(suggestionReason?.textContent?.trim()).toBe('Useful starting points across Stella Ops.');
|
||||
});
|
||||
|
||||
it('renders page-owned common questions in the empty state', () => {
|
||||
component.onFocus();
|
||||
fixture.detectChanges();
|
||||
|
||||
const questionButtons = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('[data-common-question]') as NodeListOf<HTMLButtonElement>,
|
||||
const starterButtons = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('[data-starter-kind]') as NodeListOf<HTMLButtonElement>,
|
||||
).map((node) => node.textContent?.trim());
|
||||
|
||||
expect(questionButtons).toContain('Why is this exploitable in my environment?');
|
||||
expect(questionButtons).toContain('What evidence blocks this release?');
|
||||
expect(questionButtons).toContain('What is the safest remediation path?');
|
||||
expect(contextTitle?.textContent?.trim()).toBe('Findings triage');
|
||||
expect(starterButtons).toContain('Why is this exploitable in my environment?');
|
||||
expect(starterButtons).toContain('What evidence blocks this release?');
|
||||
expect(starterButtons).toContain('How do I deploy?');
|
||||
expect(fixture.nativeElement.querySelector('.search__domain-card')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.search__quick-link')).toBeNull();
|
||||
});
|
||||
|
||||
it('queries unified search for one-character query terms', async () => {
|
||||
@@ -548,7 +528,8 @@ describe('GlobalSearchComponent', () => {
|
||||
expect(fixture.nativeElement.querySelector('[data-role="domain-filter"]')).toBeNull();
|
||||
const overflowSection = fixture.nativeElement.querySelector('[data-overflow-results]') as HTMLElement | null;
|
||||
expect(overflowSection).not.toBeNull();
|
||||
expect(overflowSection?.textContent).toContain('Also relevant outside Knowledge');
|
||||
expect(fixture.nativeElement.querySelector('.search__scope-hint')).toBeNull();
|
||||
expect(overflowSection?.textContent).toContain('Also relevant elsewhere');
|
||||
expect(overflowSection?.textContent).toContain('Related policy evidence is relevant but secondary to the Doctor page context.');
|
||||
});
|
||||
|
||||
|
||||
@@ -66,26 +66,17 @@ test.describe('Unified Search - Contextual Suggestions', () => {
|
||||
await searchInput.focus();
|
||||
await expect(page.locator('.search__results')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator('.search__context-title')).toContainText(/findings triage/i);
|
||||
await expect(page.locator('.search__context-token', {
|
||||
hasText: /scope:\s+findings/i,
|
||||
}).first()).toBeVisible();
|
||||
await expect(page.locator('.search__domain-guide')).toHaveCount(0);
|
||||
await expect(page.locator('.search__quick-link')).toHaveCount(0);
|
||||
await expect(page.locator('.search__suggestions .search__chip', {
|
||||
hasText: /critical findings/i,
|
||||
}).first()).toBeVisible();
|
||||
await expect(
|
||||
page.locator('.search__suggestion-card', {
|
||||
has: page.locator('.search__chip', { hasText: /^critical findings$/i }),
|
||||
}).locator('.search__suggestion-reason'),
|
||||
).toContainText(/triage pivots/i);
|
||||
|
||||
await page.goto('/ops/policy');
|
||||
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
|
||||
await searchInput.focus();
|
||||
await expect(page.locator('.search__results')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator('.search__context-title')).toContainText(/policy workspace/i);
|
||||
await expect(page.locator('.search__context-token', {
|
||||
hasText: /scope:\s+policy rules/i,
|
||||
}).first()).toBeVisible();
|
||||
await expect(page.locator('.search__suggestions .search__chip', {
|
||||
hasText: /failing policy gates/i,
|
||||
}).first()).toBeVisible();
|
||||
@@ -134,9 +125,6 @@ test.describe('Unified Search - Contextual Suggestions', () => {
|
||||
await expect(page.locator('.search__suggestions .search__chip', {
|
||||
hasText: /follow up:\s*CVE-2024-21626/i,
|
||||
}).first()).toBeVisible();
|
||||
await expect(page.locator('.search__suggestion-card--recent .search__suggestion-reason').first()).toContainText(
|
||||
/last actions on this page/i,
|
||||
);
|
||||
});
|
||||
|
||||
test('clicking a contextual chip keeps the search surface open and renders results', async ({ page }) => {
|
||||
|
||||
@@ -131,9 +131,8 @@ test.describe('Unified Search - Live contextual suggestions', () => {
|
||||
await waitForResults(page);
|
||||
|
||||
await expect(page.locator('.search__context-title')).toContainText(/doctor diagnostics/i);
|
||||
await expect(page.locator('.search__context-token', {
|
||||
hasText: /scope:\s+knowledge/i,
|
||||
}).first()).toBeVisible();
|
||||
await expect(page.locator('.search__domain-guide')).toHaveCount(0);
|
||||
await expect(page.locator('.search__quick-link')).toHaveCount(0);
|
||||
await expect(page.locator('.search__suggestions .search__chip', {
|
||||
hasText: /database connectivity/i,
|
||||
}).first()).toBeVisible();
|
||||
@@ -160,9 +159,14 @@ test.describe('Unified Search - Live contextual suggestions', () => {
|
||||
await openDoctor(page);
|
||||
await searchInput.focus();
|
||||
await waitForResults(page);
|
||||
await page.locator('.search__suggestions .search__chip', {
|
||||
const suggestionChip = page.locator('.search__suggestions .search__chip', {
|
||||
hasText: new RegExp(`^${escapeRegExp(suggestionText)}$`, 'i'),
|
||||
}).first().click();
|
||||
}).first();
|
||||
if (await suggestionChip.isVisible().catch(() => false)) {
|
||||
await suggestionChip.click();
|
||||
} else {
|
||||
await searchInput.fill(suggestionText);
|
||||
}
|
||||
|
||||
await expect(searchInput).toHaveValue(suggestionText);
|
||||
await waitForResults(page);
|
||||
|
||||
@@ -161,6 +161,37 @@ test.describe('Unified Search - Experience Quality UX', () => {
|
||||
expect(String(turnBody['content'] ?? '')).toMatch(/critical findings/i);
|
||||
});
|
||||
|
||||
test('keeps the empty state to page context, recent history, and executable starters only', async ({ page }) => {
|
||||
await mockSearchResponses(page, (query) => {
|
||||
if (query.includes('critical findings')) {
|
||||
return criticalFindingResponse;
|
||||
}
|
||||
|
||||
return emptyResponse(query);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
await expect(page.locator('.search__context-title')).toContainText(/findings triage/i);
|
||||
await expect(page.locator('.search__domain-guide')).toHaveCount(0);
|
||||
await expect(page.locator('.search__quick-link')).toHaveCount(0);
|
||||
|
||||
const starterChip = page.locator('[data-starter-kind]', {
|
||||
hasText: /critical findings/i,
|
||||
}).first();
|
||||
await expect(starterChip).toBeVisible();
|
||||
|
||||
await starterChip.click();
|
||||
await expect(searchInput).toHaveValue(/critical findings/i);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page, 1);
|
||||
});
|
||||
|
||||
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) => {
|
||||
@@ -191,6 +222,8 @@ test.describe('Unified Search - Experience Quality UX', () => {
|
||||
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);
|
||||
await expect(page.locator('.search__domain-guide')).toHaveCount(0);
|
||||
await expect(page.locator('.search__quick-link')).toHaveCount(0);
|
||||
|
||||
const searchBarBox = await searchBar.boundingBox();
|
||||
const didYouMeanBox = await didYouMean.boundingBox();
|
||||
@@ -276,8 +309,8 @@ test.describe('Unified Search - Experience Quality UX', () => {
|
||||
await expect(page.locator('[data-answer-status="grounded"]')).toContainText(
|
||||
/current-page findings matched first/i,
|
||||
);
|
||||
await expect(page.locator('.search__scope-hint')).toContainText(/weighted toward findings/i);
|
||||
await expect(page.locator('[data-overflow-results]')).toContainText(/also relevant outside findings/i);
|
||||
await expect(page.locator('.search__scope-hint')).toHaveCount(0);
|
||||
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);
|
||||
|
||||
@@ -80,7 +80,7 @@ test.describe('Unified Search - Self-Serve Answer Panel', () => {
|
||||
|
||||
await page.locator('app-global-search input[type="text"]').focus();
|
||||
await waitForResults(page);
|
||||
const commonQuestions = page.locator('[data-common-question]');
|
||||
const commonQuestions = page.locator('[data-starter-kind="question"]');
|
||||
await expect(commonQuestions).toHaveCount(3);
|
||||
const commonQuestionTexts = (await commonQuestions.allTextContents()).map((text) => text.trim());
|
||||
expect(commonQuestionTexts).toEqual(expect.arrayContaining([
|
||||
@@ -88,6 +88,8 @@ test.describe('Unified Search - Self-Serve Answer Panel', () => {
|
||||
'What evidence blocks this release?',
|
||||
'What is the safest remediation path?',
|
||||
]));
|
||||
await expect(page.locator('.search__domain-guide')).toHaveCount(0);
|
||||
await expect(page.locator('.search__quick-link')).toHaveCount(0);
|
||||
|
||||
await typeInSearch(page, 'critical findings');
|
||||
await waitForResults(page);
|
||||
|
||||
Reference in New Issue
Block a user