Collapse search into zero-learning starters

This commit is contained in:
master
2026-03-07 19:48:46 +02:00
parent 1088ae1bc4
commit f23ca585d4
8 changed files with 175 additions and 324 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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',

View File

@@ -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.');
});

View File

@@ -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 }) => {

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);