Collapse search into zero-learning starters
This commit is contained in:
@@ -17,7 +17,7 @@
|
|||||||
## Delivery Tracker
|
## Delivery Tracker
|
||||||
|
|
||||||
### FE-SC-101 - Collapse empty-state education
|
### FE-SC-101 - Collapse empty-state education
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: none
|
Dependency: none
|
||||||
Owners: Developer
|
Owners: Developer
|
||||||
Task description:
|
Task description:
|
||||||
@@ -25,12 +25,12 @@ Task description:
|
|||||||
- Replace them with a small set of viable, page-aware search starts only when useful.
|
- Replace them with a small set of viable, page-aware search starts only when useful.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Empty-state UI does not present domain cards or "learn Stella" quick links as the main action.
|
- [x] 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.
|
- [x] Suggestions shown in the empty state remain executable and page-aware.
|
||||||
- [ ] Search history remains successful-only and is visually low-emphasis.
|
- [x] Search history remains successful-only and is visually low-emphasis.
|
||||||
|
|
||||||
### FE-SC-102 - Simplify in-result cues
|
### FE-SC-102 - Simplify in-result cues
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-SC-101
|
Dependency: FE-SC-101
|
||||||
Owners: Developer
|
Owners: Developer
|
||||||
Task description:
|
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.
|
- Stop explaining scope weighting mechanics in the main flow; show the better in-scope answer first, then overflow only when needed.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] `Did you mean` is visually attached to the input.
|
- [x] `Did you mean` is visually attached to the input.
|
||||||
- [ ] Scope weighting hints are removed or translated into plain operator-facing result labels.
|
- [x] 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] Overflow only appears when present and is visually secondary to the best in-scope answer.
|
||||||
|
|
||||||
### FE-SC-103 - Harden suggestion and history behavior
|
### FE-SC-103 - Harden suggestion and history behavior
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-SC-102
|
Dependency: FE-SC-102
|
||||||
Owners: Developer, Test Automation
|
Owners: Developer, Test Automation
|
||||||
Task description:
|
Task description:
|
||||||
@@ -51,14 +51,15 @@ Task description:
|
|||||||
- Exercise user flows that previously felt broken from the empty state.
|
- Exercise user flows that previously felt broken from the empty state.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] No-result queries do not reappear in rendered history.
|
- [x] No-result queries do not reappear in rendered history.
|
||||||
- [ ] Suggestion clicks from the empty state remain non-dead-end flows.
|
- [x] Suggestion clicks from the empty state remain non-dead-end flows.
|
||||||
- [ ] Playwright covers history, suggestions, did-you-mean placement, and overflow presentation.
|
- [x] Playwright covers history, suggestions, did-you-mean placement, and overflow presentation.
|
||||||
|
|
||||||
## Execution Log
|
## Execution Log
|
||||||
| Date (UTC) | Update | Owner |
|
| 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 | 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
|
## Decisions & Risks
|
||||||
- Decision: the empty state should help the operator start, not explain Stella's information architecture.
|
- 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.
|
- Fail fast when corpus rebuild/readiness is missing so dead suggestions are treated as setup failures, not flaky UI tests.
|
||||||
|
|
||||||
## Current state
|
## 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.
|
- 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;
|
query: string;
|
||||||
kind: 'page' | 'clarify' | 'recent';
|
kind: 'page' | 'clarify' | 'recent';
|
||||||
};
|
};
|
||||||
|
type SearchStarterView = {
|
||||||
|
query: string;
|
||||||
|
kind: 'question' | 'suggestion';
|
||||||
|
};
|
||||||
type SearchContextPanelView = {
|
type SearchContextPanelView = {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -211,14 +215,10 @@ type SearchAnswerView = {
|
|||||||
} @else if (query().trim().length >= 1 && visibleCards().length === 0) {
|
} @else if (query().trim().length >= 1 && visibleCards().length === 0) {
|
||||||
<div class="search__empty">{{ t('ui.search.no_results', 'No results found') }}</div>
|
<div class="search__empty">{{ t('ui.search.no_results', 'No results found') }}</div>
|
||||||
} @else if (query().trim().length >= 1) {
|
} @else if (query().trim().length >= 1) {
|
||||||
@if (scopeWeightingHint(); as scopeHint) {
|
|
||||||
<div class="search__scope-hint">{{ scopeHint }}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (cards().length > 0) {
|
@if (cards().length > 0) {
|
||||||
<div class="search__cards-section">
|
<div class="search__cards-section">
|
||||||
@if (overflowCards().length > 0) {
|
@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'">
|
<div class="search__cards" role="list" [attr.aria-live]="'polite'">
|
||||||
@for (card of cards(); track card.entityKey; let i = $index) {
|
@for (card of cards(); track card.entityKey; let i = $index) {
|
||||||
@@ -269,7 +269,7 @@ type SearchAnswerView = {
|
|||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
@if (recentSearches().length > 0) {
|
@if (recentSearches().length > 0) {
|
||||||
<div class="search__group">
|
<div class="search__group search__group--recent">
|
||||||
<div class="search__group-header">
|
<div class="search__group-header">
|
||||||
<div class="search__group-label">{{ t('ui.search.recent_label', 'Recent') }}</div>
|
<div class="search__group-label">{{ t('ui.search.recent_label', 'Recent') }}</div>
|
||||||
<button
|
<button
|
||||||
@@ -306,105 +306,45 @@ type SearchAnswerView = {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchContextPanel(); as contextPanel) {
|
@if (starterContextTitle(); as contextTitle) {
|
||||||
<div class="search__context-rail">
|
<div class="search__starter-context">
|
||||||
<div class="search__group-label">{{ t('ui.search.context.label', 'Context') }}</div>
|
<div class="search__context-title">{{ contextTitle }}</div>
|
||||||
<div class="search__context-card">
|
@if (starterContextTokens().length > 0) {
|
||||||
<div class="search__context-title">{{ contextPanel.title }}</div>
|
|
||||||
<div class="search__context-description">{{ contextPanel.description }}</div>
|
|
||||||
<div class="search__context-tokens">
|
<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">
|
||||||
<span class="search__context-token-label">{{ token.label }}:</span>
|
<span class="search__context-token-label">{{ token.label }}:</span>
|
||||||
<span class="search__context-token-value"> {{ token.value }}</span>
|
<span class="search__context-token-value"> {{ token.value }}</span>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (commonQuestions().length > 0) {
|
@if (starterQueries().length > 0) {
|
||||||
<div class="search__questions">
|
<div class="search__suggestions">
|
||||||
<div class="search__group-label">{{ t('ui.search.questions.label', 'Common questions') }}</div>
|
<div class="search__group-label">{{ t('ui.search.starters.label', 'Try asking') }}</div>
|
||||||
<div class="search__question-chips">
|
<div class="search__starter-chips">
|
||||||
@for (question of commonQuestions(); track question.query) {
|
@for (starter of starterQueries(); track starter.query) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="search__question-chip"
|
class="search__question-chip search__starter-chip search__chip"
|
||||||
[attr.data-common-question]="question.kind"
|
[attr.data-starter-kind]="starter.kind"
|
||||||
(click)="applyQuestionQuery(question.query, 'common')"
|
(click)="applyStarterQuery(starter)"
|
||||||
>
|
>
|
||||||
{{ question.query }}
|
{{ starter.query }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="search__suggestions">
|
@if (recentSearches().length === 0 && starterQueries().length === 0) {
|
||||||
<div class="search__group-label">{{ t('ui.search.suggested_label', 'Suggested') }}</div>
|
<div class="search__empty-state-copy">
|
||||||
<div class="search__suggestion-cards">
|
{{ t('ui.search.empty_prompt', 'Ask about what is on this page.') }}
|
||||||
@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>
|
|
||||||
}
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -560,12 +500,6 @@ type SearchAnswerView = {
|
|||||||
border-bottom: 1px solid var(--color-border-primary);
|
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 {
|
.search__cards-section {
|
||||||
padding: 0.25rem 0 0.5rem;
|
padding: 0.25rem 0 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -598,6 +532,10 @@ type SearchAnswerView = {
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search__group--recent {
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
.search__group-label {
|
.search__group-label {
|
||||||
padding: 0.25rem 0.75rem;
|
padding: 0.25rem 0.75rem;
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
@@ -661,17 +599,8 @@ type SearchAnswerView = {
|
|||||||
background: var(--color-nav-hover);
|
background: var(--color-nav-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search__context-rail {
|
.search__starter-context {
|
||||||
padding: 0.5rem 0;
|
padding: 0.75rem 0.75rem 0.25rem;
|
||||||
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__context-title {
|
.search__context-title {
|
||||||
@@ -680,13 +609,6 @@ type SearchAnswerView = {
|
|||||||
color: var(--color-text-primary);
|
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 {
|
.search__context-tokens {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -868,15 +790,14 @@ type SearchAnswerView = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search__suggestions {
|
.search__suggestions {
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0 0.75rem;
|
||||||
border-bottom: 1px solid var(--color-border-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search__suggestion-cards {
|
.search__starter-chips {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.25rem 0.75rem;
|
padding: 0.25rem 0.75rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search__chip {
|
.search__chip {
|
||||||
@@ -898,138 +819,42 @@ type SearchAnswerView = {
|
|||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search__suggestion-card {
|
.search__starter-chip {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
align-items: flex-start;
|
padding: 0.375rem 0.625rem;
|
||||||
gap: 0.375rem;
|
border: 1px solid var(--color-border-secondary);
|
||||||
padding: 0.5rem;
|
border-radius: 999px;
|
||||||
border: 1px solid var(--color-border-primary);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--color-surface-primary);
|
background: var(--color-surface-primary);
|
||||||
}
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.75rem;
|
||||||
.search__suggestion-card--recent {
|
line-height: 1.2;
|
||||||
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;
|
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
max-width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search__suggestion-reason {
|
.search__starter-chip[data-starter-kind='question'] {
|
||||||
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);
|
|
||||||
border-color: var(--color-brand-primary-20, #bfdbfe);
|
border-color: var(--color-brand-primary-20, #bfdbfe);
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search__chip--example:hover {
|
|
||||||
background: var(--color-brand-primary-10, #eff6ff);
|
background: var(--color-brand-primary-10, #eff6ff);
|
||||||
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search__domain-guide {
|
.search__starter-chip:hover {
|
||||||
padding: 0.5rem 0;
|
border-color: var(--color-brand-primary, #1d4ed8);
|
||||||
border-bottom: 1px solid var(--color-border-primary);
|
background: var(--color-brand-primary-10, #eff6ff);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search__domain-grid {
|
.search__empty-state-copy {
|
||||||
display: grid;
|
padding: 0.75rem;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
color: var(--color-text-muted);
|
||||||
gap: 0.5rem;
|
font-size: 0.75rem;
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.search__answer-header {
|
.search__answer-header {
|
||||||
flex-direction: column;
|
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 {
|
.did-you-mean {
|
||||||
@@ -1076,10 +901,6 @@ type SearchAnswerView = {
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search__quick-link {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search__question-chip,
|
.search__question-chip,
|
||||||
.search__answer-assistant,
|
.search__answer-assistant,
|
||||||
.search__answer-next-search {
|
.search__answer-next-search {
|
||||||
@@ -1404,32 +1225,42 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
readonly visibleCards = computed(() => [...this.cards(), ...this.overflowCards()]);
|
readonly visibleCards = computed(() => [...this.cards(), ...this.overflowCards()]);
|
||||||
readonly synthesis = computed(() => this.searchResponse()?.synthesis ?? null);
|
readonly synthesis = computed(() => this.searchResponse()?.synthesis ?? null);
|
||||||
readonly filteredCards = computed(() => this.visibleCards());
|
readonly filteredCards = computed(() => this.visibleCards());
|
||||||
readonly scopeWeightingHint = computed(() => {
|
readonly starterContextTitle = computed(() => this.searchContextPanel()?.title ?? null);
|
||||||
const coverage = this.searchResponse()?.coverage;
|
readonly starterContextTokens = computed(() =>
|
||||||
if (!coverage?.currentScopeWeighted || !coverage.currentScopeDomain) {
|
(this.searchContextPanel()?.tokens ?? []).filter((token) => token.key === 'last-action'));
|
||||||
return null;
|
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]
|
const seen = new Set<string>();
|
||||||
?? coverage.currentScopeDomain;
|
return starters
|
||||||
return this.t(
|
.filter((starter) => {
|
||||||
'ui.search.scope_weighting',
|
const normalized = starter.query.trim().toLowerCase();
|
||||||
'Weighted toward {domain} because of the current page.',
|
if (!normalized || seen.has(normalized)) {
|
||||||
{ domain: domainLabel },
|
return false;
|
||||||
);
|
}
|
||||||
});
|
|
||||||
readonly overflowSectionTitle = computed(() => {
|
seen.add(normalized);
|
||||||
const scopeDomain = this.searchResponse()?.overflow?.currentScopeDomain
|
return true;
|
||||||
|| this.searchResponse()?.coverage?.currentScopeDomain;
|
})
|
||||||
const scopeLabel = scopeDomain
|
.slice(0, 6)
|
||||||
? (DOMAIN_LABELS[scopeDomain as UnifiedSearchDomain] ?? scopeDomain)
|
.map((starter) => ({
|
||||||
: this.t('ui.search.scope.default', 'this page');
|
query: starter.query,
|
||||||
return this.t(
|
kind: starter.kind,
|
||||||
'ui.search.results.overflow',
|
}));
|
||||||
'Also relevant outside {scope}',
|
|
||||||
{ scope: scopeLabel },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
readonly overflowSectionTitle = computed(() =>
|
||||||
|
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(
|
||||||
@@ -1738,6 +1569,15 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
this.keepSearchSurfaceOpen();
|
this.keepSearchSurfaceOpen();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyStarterQuery(starter: SearchStarterView): void {
|
||||||
|
if (starter.kind === 'question') {
|
||||||
|
this.applyQuestionQuery(starter.query, 'common');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.applyExampleQuery(starter.query);
|
||||||
|
}
|
||||||
|
|
||||||
applyExampleQuery(example: string): void {
|
applyExampleQuery(example: string): void {
|
||||||
this.recordAmbientAction('search_example', {
|
this.recordAmbientAction('search_example', {
|
||||||
source: 'global_search_example_chip',
|
source: 'global_search_example_chip',
|
||||||
|
|||||||
@@ -195,41 +195,21 @@ describe('GlobalSearchComponent', () => {
|
|||||||
expect(placeholder).not.toContain('{suggestion}');
|
expect(placeholder).not.toContain('{suggestion}');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders eight domain cards in empty state guide', () => {
|
it('collapses the empty state into current-page starters without product teaching panels', () => {
|
||||||
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', () => {
|
|
||||||
component.onFocus();
|
component.onFocus();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const contextTitle = fixture.nativeElement.querySelector('.search__context-title') as HTMLElement | null;
|
const contextTitle = fixture.nativeElement.querySelector('.search__context-title') as HTMLElement | null;
|
||||||
const contextTokens = Array.from(
|
const starterButtons = Array.from(
|
||||||
fixture.nativeElement.querySelectorAll('.search__context-token') as NodeListOf<Element>,
|
fixture.nativeElement.querySelectorAll('[data-starter-kind]') as NodeListOf<HTMLButtonElement>,
|
||||||
).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>,
|
|
||||||
).map((node) => node.textContent?.trim());
|
).map((node) => node.textContent?.trim());
|
||||||
|
|
||||||
expect(questionButtons).toContain('Why is this exploitable in my environment?');
|
expect(contextTitle?.textContent?.trim()).toBe('Findings triage');
|
||||||
expect(questionButtons).toContain('What evidence blocks this release?');
|
expect(starterButtons).toContain('Why is this exploitable in my environment?');
|
||||||
expect(questionButtons).toContain('What is the safest remediation path?');
|
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 () => {
|
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();
|
expect(fixture.nativeElement.querySelector('[data-role="domain-filter"]')).toBeNull();
|
||||||
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(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.');
|
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 searchInput.focus();
|
||||||
await expect(page.locator('.search__results')).toBeVisible({ timeout: 10_000 });
|
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-title')).toContainText(/findings triage/i);
|
||||||
await expect(page.locator('.search__context-token', {
|
await expect(page.locator('.search__domain-guide')).toHaveCount(0);
|
||||||
hasText: /scope:\s+findings/i,
|
await expect(page.locator('.search__quick-link')).toHaveCount(0);
|
||||||
}).first()).toBeVisible();
|
|
||||||
await expect(page.locator('.search__suggestions .search__chip', {
|
await expect(page.locator('.search__suggestions .search__chip', {
|
||||||
hasText: /critical findings/i,
|
hasText: /critical findings/i,
|
||||||
}).first()).toBeVisible();
|
}).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 page.goto('/ops/policy');
|
||||||
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
|
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
|
||||||
await searchInput.focus();
|
await searchInput.focus();
|
||||||
await expect(page.locator('.search__results')).toBeVisible({ timeout: 10_000 });
|
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-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', {
|
await expect(page.locator('.search__suggestions .search__chip', {
|
||||||
hasText: /failing policy gates/i,
|
hasText: /failing policy gates/i,
|
||||||
}).first()).toBeVisible();
|
}).first()).toBeVisible();
|
||||||
@@ -134,9 +125,6 @@ test.describe('Unified Search - Contextual Suggestions', () => {
|
|||||||
await expect(page.locator('.search__suggestions .search__chip', {
|
await expect(page.locator('.search__suggestions .search__chip', {
|
||||||
hasText: /follow up:\s*CVE-2024-21626/i,
|
hasText: /follow up:\s*CVE-2024-21626/i,
|
||||||
}).first()).toBeVisible();
|
}).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 }) => {
|
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 waitForResults(page);
|
||||||
|
|
||||||
await expect(page.locator('.search__context-title')).toContainText(/doctor diagnostics/i);
|
await expect(page.locator('.search__context-title')).toContainText(/doctor diagnostics/i);
|
||||||
await expect(page.locator('.search__context-token', {
|
await expect(page.locator('.search__domain-guide')).toHaveCount(0);
|
||||||
hasText: /scope:\s+knowledge/i,
|
await expect(page.locator('.search__quick-link')).toHaveCount(0);
|
||||||
}).first()).toBeVisible();
|
|
||||||
await expect(page.locator('.search__suggestions .search__chip', {
|
await expect(page.locator('.search__suggestions .search__chip', {
|
||||||
hasText: /database connectivity/i,
|
hasText: /database connectivity/i,
|
||||||
}).first()).toBeVisible();
|
}).first()).toBeVisible();
|
||||||
@@ -160,9 +159,14 @@ test.describe('Unified Search - Live contextual suggestions', () => {
|
|||||||
await openDoctor(page);
|
await openDoctor(page);
|
||||||
await searchInput.focus();
|
await searchInput.focus();
|
||||||
await waitForResults(page);
|
await waitForResults(page);
|
||||||
await page.locator('.search__suggestions .search__chip', {
|
const suggestionChip = page.locator('.search__suggestions .search__chip', {
|
||||||
hasText: new RegExp(`^${escapeRegExp(suggestionText)}$`, 'i'),
|
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 expect(searchInput).toHaveValue(suggestionText);
|
||||||
await waitForResults(page);
|
await waitForResults(page);
|
||||||
|
|||||||
@@ -161,6 +161,37 @@ test.describe('Unified Search - Experience Quality UX', () => {
|
|||||||
expect(String(turnBody['content'] ?? '')).toMatch(/critical findings/i);
|
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 }) => {
|
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) => {
|
||||||
@@ -191,6 +222,8 @@ test.describe('Unified Search - Experience Quality UX', () => {
|
|||||||
await expect(page.locator('.search__segment')).toHaveCount(0);
|
await expect(page.locator('.search__segment')).toHaveCount(0);
|
||||||
await expect(page.locator('.search__scope-chip')).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__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 searchBarBox = await searchBar.boundingBox();
|
||||||
const didYouMeanBox = await didYouMean.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(
|
await expect(page.locator('[data-answer-status="grounded"]')).toContainText(
|
||||||
/current-page findings matched first/i,
|
/current-page findings matched first/i,
|
||||||
);
|
);
|
||||||
await expect(page.locator('.search__scope-hint')).toContainText(/weighted toward findings/i);
|
await expect(page.locator('.search__scope-hint')).toHaveCount(0);
|
||||||
await expect(page.locator('[data-overflow-results]')).toContainText(/also relevant outside findings/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);
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ test.describe('Unified Search - Self-Serve Answer Panel', () => {
|
|||||||
|
|
||||||
await page.locator('app-global-search input[type="text"]').focus();
|
await page.locator('app-global-search input[type="text"]').focus();
|
||||||
await waitForResults(page);
|
await waitForResults(page);
|
||||||
const commonQuestions = page.locator('[data-common-question]');
|
const commonQuestions = page.locator('[data-starter-kind="question"]');
|
||||||
await expect(commonQuestions).toHaveCount(3);
|
await expect(commonQuestions).toHaveCount(3);
|
||||||
const commonQuestionTexts = (await commonQuestions.allTextContents()).map((text) => text.trim());
|
const commonQuestionTexts = (await commonQuestions.allTextContents()).map((text) => text.trim());
|
||||||
expect(commonQuestionTexts).toEqual(expect.arrayContaining([
|
expect(commonQuestionTexts).toEqual(expect.arrayContaining([
|
||||||
@@ -88,6 +88,8 @@ test.describe('Unified Search - Self-Serve Answer Panel', () => {
|
|||||||
'What evidence blocks this release?',
|
'What evidence blocks this release?',
|
||||||
'What is the safest remediation path?',
|
'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 typeInSearch(page, 'critical findings');
|
||||||
await waitForResults(page);
|
await waitForResults(page);
|
||||||
|
|||||||
Reference in New Issue
Block a user