Add answer-first self-serve search UX

This commit is contained in:
master
2026-03-07 01:21:14 +02:00
parent 107d38a3be
commit 803940bd36
15 changed files with 1536 additions and 6 deletions

View File

@@ -9,14 +9,19 @@ import type {
UnifiedSearchFilter,
} from '../api/unified-search.models';
import {
DEFAULT_CLARIFYING_QUESTIONS,
DEFAULT_COMMON_QUESTIONS,
DEFAULT_CHAT_SUGGESTIONS,
DEFAULT_SEARCH_SUGGESTIONS,
SEARCH_CONTEXT_DEFINITIONS,
type SearchContextDefinition,
type SearchQuestionChip,
type SearchSuggestionChip,
} from './search-context.registry';
import type { SearchExperienceMode } from './search-experience-mode.service';
export type ContextSuggestion = SearchSuggestionChip;
export type ContextQuestion = SearchQuestionChip;
export interface SearchContextPanelToken {
key: string;
@@ -114,7 +119,7 @@ export class AmbientContextService {
const recentActions = this.getActiveActions(scopeKey);
const actionSuggestions = this.buildRecentActionSuggestions(recentActions, 2);
const strategicSuggestion = this.buildStrategicSuggestion(scope, recentActions);
const rotatedRouteSuggestions = this.rotateSuggestions(routeSuggestions, `${scope}|${scopeKey}`);
const rotatedRouteSuggestions = this.rotateEntries(routeSuggestions, `${scope}|${scopeKey}`);
const deduped = [...actionSuggestions, strategicSuggestion, ...rotatedRouteSuggestions]
.filter((entry): entry is ContextSuggestion => entry !== null)
@@ -125,6 +130,39 @@ export class AmbientContextService {
return deduped.slice(0, 4);
}
getCommonQuestions(mode: SearchExperienceMode): readonly ContextQuestion[] {
const route = this.routeUrl();
const scopeKey = this.routeScope(route);
const context = this.findContext(route, (candidate) =>
Array.isArray(candidate.selfServe?.commonQuestions)
&& candidate.selfServe!.commonQuestions!.length > 0,
);
const baseQuestions = context?.selfServe?.commonQuestions ?? DEFAULT_COMMON_QUESTIONS;
const recentActions = this.getActiveActions(scopeKey);
const recentQuestion = this.buildRecentActionQuestion(recentActions[0] ?? null, mode);
const rotatedQuestions = this.rotateEntries(baseQuestions, `${scopeKey}|common|${mode}`);
return [recentQuestion, ...rotatedQuestions]
.filter((entry): entry is ContextQuestion => entry !== null)
.filter((entry, index, list) =>
list.findIndex((candidate) => candidate.fallback.toLowerCase() === entry.fallback.toLowerCase()) === index,
)
.slice(0, 4);
}
getClarifyingQuestions(mode: SearchExperienceMode): readonly ContextQuestion[] {
const route = this.routeUrl();
const scopeKey = this.routeScope(route);
const context = this.findContext(route, (candidate) =>
Array.isArray(candidate.selfServe?.clarifyingQuestions)
&& candidate.selfServe!.clarifyingQuestions!.length > 0,
);
const baseQuestions = context?.selfServe?.clarifyingQuestions ?? DEFAULT_CLARIFYING_QUESTIONS;
return this.rotateEntries(baseQuestions, `${scopeKey}|clarify|${mode}`).slice(0, 3);
}
getChatSuggestions(): readonly ContextSuggestion[] {
const route = this.routeUrl();
const context = this.findContext(route, (candidate) => {
@@ -375,6 +413,34 @@ export class AmbientContextService {
};
}
private buildRecentActionQuestion(
action: UnifiedSearchAmbientAction | null,
mode: SearchExperienceMode,
): ContextQuestion | null {
if (!action) {
return null;
}
const hint = this.buildActionHint(action);
if (!hint) {
return null;
}
let fallback = `What else is related to ${hint}?`;
if (mode === 'explain') {
fallback = `Why does ${hint} matter on this page?`;
} else if (mode === 'act') {
fallback = `What should I do next for ${hint}?`;
}
return {
key: 'ui.search.question.recent_action.default',
fallback,
kind: 'recent',
preferredModes: [mode],
};
}
private describeAction(action: UnifiedSearchAmbientAction): string | null {
const hint = this.buildActionHint(action);
const normalizedAction = action.action.trim().toLowerCase();
@@ -511,10 +577,10 @@ export class AmbientContextService {
}
}
private rotateSuggestions(
suggestions: readonly ContextSuggestion[],
private rotateEntries<T extends { fallback: string }>(
suggestions: readonly T[],
scope: string,
): readonly ContextSuggestion[] {
): readonly T[] {
if (suggestions.length <= 1) {
return suggestions;
}

View File

@@ -2,6 +2,7 @@ import type { UnifiedSearchDomain } from '../api/unified-search.models';
import type { SearchExperienceMode } from './search-experience-mode.service';
export type SearchSuggestionKind = 'page' | 'recent' | 'strategy';
export type SearchQuestionKind = 'page' | 'clarify' | 'recent';
export interface SearchSuggestionChip {
key: string;
@@ -12,6 +13,18 @@ export interface SearchSuggestionChip {
preferredModes?: readonly SearchExperienceMode[];
}
export interface SearchQuestionChip {
key: string;
fallback: string;
kind?: SearchQuestionKind;
preferredModes?: readonly SearchExperienceMode[];
}
export interface SearchSelfServeDefinition {
commonQuestions?: readonly SearchQuestionChip[];
clarifyingQuestions?: readonly SearchQuestionChip[];
}
export interface SearchContextPresentation {
titleKey: string;
titleFallback: string;
@@ -26,6 +39,7 @@ export interface SearchContextDefinition {
domain?: UnifiedSearchDomain;
searchSuggestions?: readonly SearchSuggestionChip[];
chatSuggestions?: readonly SearchSuggestionChip[];
selfServe?: SearchSelfServeDefinition;
chatRoutePattern?: RegExp;
}
@@ -51,6 +65,16 @@ function withReason(
}));
}
function withQuestionKind(
questions: readonly SearchQuestionChip[],
kind: SearchQuestionKind,
): readonly SearchQuestionChip[] {
return questions.map((question) => ({
...question,
kind: question.kind ?? kind,
}));
}
export const DEFAULT_SEARCH_SUGGESTIONS: readonly SearchSuggestionChip[] = withReason([
{ key: 'ui.search.suggestion.default.deploy', fallback: 'How do I deploy?', preferredModes: ['act'] },
{ key: 'ui.search.suggestion.default.vex', fallback: 'What is a VEX statement?', preferredModes: ['explain'] },
@@ -64,6 +88,25 @@ export const DEFAULT_CHAT_SUGGESTIONS: readonly SearchSuggestionChip[] = [
{ key: 'ui.chat.suggestion.default.health_checks', fallback: 'What health checks should I run first?' },
];
export const DEFAULT_COMMON_QUESTIONS: readonly SearchQuestionChip[] = withQuestionKind([
{ key: 'ui.search.question.default.changed', fallback: 'What changed most recently?', preferredModes: ['find', 'explain'] },
{ key: 'ui.search.question.default.evidence', fallback: 'Show me the strongest evidence', preferredModes: ['explain'] },
{ key: 'ui.search.question.default.next_step', fallback: 'What should I inspect next?', preferredModes: ['act'] },
], 'page');
export const DEFAULT_CLARIFYING_QUESTIONS: readonly SearchQuestionChip[] = withQuestionKind([
{
key: 'ui.search.question.default.clarify_target',
fallback: 'Which workload, release, or policy should I narrow this to?',
preferredModes: ['find', 'explain'],
},
{
key: 'ui.search.question.default.clarify_scope',
fallback: 'Should I stay on this page or broaden to all domains?',
preferredModes: ['act'],
},
], 'clarify');
const FINDINGS_SEARCH_SUGGESTIONS: readonly SearchSuggestionChip[] = withReason([
{ key: 'ui.search.suggestion.findings.critical', fallback: 'critical findings', preferredModes: ['find'] },
{ key: 'ui.search.suggestion.findings.reachable', fallback: 'reachable vulnerabilities', preferredModes: ['find', 'explain'] },
@@ -108,6 +151,102 @@ const POLICY_CHAT_SUGGESTIONS: readonly SearchSuggestionChip[] = [
{ key: 'ui.chat.suggestion.policy.add_exception', fallback: 'How do I add an exception?' },
];
const FINDINGS_SELF_SERVE: SearchSelfServeDefinition = {
commonQuestions: withQuestionKind([
{ key: 'ui.search.question.findings.exploitable', fallback: 'Why is this exploitable in my environment?', preferredModes: ['explain'] },
{ key: 'ui.search.question.findings.release_blocker', fallback: 'What evidence blocks this release?', preferredModes: ['act', 'explain'] },
{ key: 'ui.search.question.findings.remediation', fallback: 'What is the safest remediation path?', preferredModes: ['act'] },
], 'page'),
clarifyingQuestions: withQuestionKind([
{ key: 'ui.search.question.findings.clarify_target', fallback: 'Which CVE, workload, or package should I narrow this to?', preferredModes: ['find', 'explain'] },
{ key: 'ui.search.question.findings.clarify_scope', fallback: 'Should I focus on reachable, production, or unresolved findings?', preferredModes: ['find', 'act'] },
], 'clarify'),
};
const VEX_SELF_SERVE: SearchSelfServeDefinition = {
commonQuestions: withQuestionKind([
{ key: 'ui.search.question.vex.why_not_affected', fallback: 'Why is this marked not affected?', preferredModes: ['explain'] },
{ key: 'ui.search.question.vex.covered_components', fallback: 'Which components are covered by this VEX?', preferredModes: ['find'] },
{ key: 'ui.search.question.vex.conflicting_evidence', fallback: 'What evidence conflicts with this VEX?', preferredModes: ['act', 'explain'] },
], 'page'),
clarifyingQuestions: withQuestionKind([
{ key: 'ui.search.question.vex.clarify_statement', fallback: 'Which statement, component, or product range should I narrow this to?', preferredModes: ['find', 'explain'] },
{ key: 'ui.search.question.vex.clarify_need', fallback: 'Do you want exploitability meaning, coverage, or conflict evidence?', preferredModes: ['act'] },
], 'clarify'),
};
const POLICY_SELF_SERVE: SearchSelfServeDefinition = {
commonQuestions: withQuestionKind([
{ key: 'ui.search.question.policy.why_failing', fallback: 'Why is this gate failing?', preferredModes: ['explain'] },
{ key: 'ui.search.question.policy.impacted_findings', fallback: 'What findings are impacted by this rule?', preferredModes: ['find', 'explain'] },
{ key: 'ui.search.question.policy.exception', fallback: 'What is the safest exception path?', preferredModes: ['act'] },
], 'page'),
clarifyingQuestions: withQuestionKind([
{ key: 'ui.search.question.policy.clarify_target', fallback: 'Which rule, environment, or control should I narrow this to?', preferredModes: ['find', 'explain'] },
{ key: 'ui.search.question.policy.clarify_need', fallback: 'Do you want recent failures, exceptions, or promotion impact?', preferredModes: ['act'] },
], 'clarify'),
};
const DOCTOR_SELF_SERVE: SearchSelfServeDefinition = {
commonQuestions: withQuestionKind([
{ key: 'ui.search.question.doctor.blocking_check', fallback: 'Which failing check is blocking release?', preferredModes: ['act', 'find'] },
{ key: 'ui.search.question.doctor.verify_fix', fallback: 'How do I verify the fix safely?', preferredModes: ['act'] },
{ key: 'ui.search.question.doctor.changed', fallback: 'What changed before this health issue?', preferredModes: ['explain'] },
], 'page'),
clarifyingQuestions: withQuestionKind([
{ key: 'ui.search.question.doctor.clarify_check', fallback: 'Which check or symptom should I narrow this to?', preferredModes: ['find'] },
{ key: 'ui.search.question.doctor.clarify_need', fallback: 'Do you want diagnosis, remediation, or verification steps?', preferredModes: ['act', 'explain'] },
], 'clarify'),
};
const GRAPH_SELF_SERVE: SearchSelfServeDefinition = {
commonQuestions: withQuestionKind([
{ key: 'ui.search.question.graph.path', fallback: 'Which path makes this reachable?', preferredModes: ['find', 'explain'] },
{ key: 'ui.search.question.graph.blast_radius', fallback: 'What is the blast radius of this node?', preferredModes: ['explain'] },
{ key: 'ui.search.question.graph.next_hop', fallback: 'What should I inspect next on this path?', preferredModes: ['act'] },
], 'page'),
clarifyingQuestions: withQuestionKind([
{ key: 'ui.search.question.graph.clarify_node', fallback: 'Which node, package, or edge should I narrow this to?', preferredModes: ['find'] },
{ key: 'ui.search.question.graph.clarify_need', fallback: 'Do you want reachability, impact, or next-step guidance?', preferredModes: ['act', 'explain'] },
], 'clarify'),
};
const OPS_MEMORY_SELF_SERVE: SearchSelfServeDefinition = {
commonQuestions: withQuestionKind([
{ key: 'ui.search.question.ops_memory.pattern', fallback: 'Have we seen this pattern before?', preferredModes: ['find', 'explain'] },
{ key: 'ui.search.question.ops_memory.runbook', fallback: 'What runbook usually fixes this fastest?', preferredModes: ['act'] },
{ key: 'ui.search.question.ops_memory.repeat', fallback: 'What repeated failures are related to this?', preferredModes: ['find'] },
], 'page'),
clarifyingQuestions: withQuestionKind([
{ key: 'ui.search.question.ops_memory.clarify_job', fallback: 'Which job, incident, or recurring failure should I narrow this to?', preferredModes: ['find'] },
{ key: 'ui.search.question.ops_memory.clarify_need', fallback: 'Do you want precedent, likely cause, or recommended recovery?', preferredModes: ['act', 'explain'] },
], 'clarify'),
};
const TIMELINE_SELF_SERVE: SearchSelfServeDefinition = {
commonQuestions: withQuestionKind([
{ key: 'ui.search.question.timeline.before_incident', fallback: 'What changed before this incident?', preferredModes: ['explain'] },
{ key: 'ui.search.question.timeline.introduced_risk', fallback: 'Which release introduced this risk?', preferredModes: ['find', 'explain'] },
{ key: 'ui.search.question.timeline.next_event', fallback: 'What else happened around this event?', preferredModes: ['act', 'find'] },
], 'page'),
clarifyingQuestions: withQuestionKind([
{ key: 'ui.search.question.timeline.clarify_window', fallback: 'Which deployment, incident, or time window should I narrow this to?', preferredModes: ['find'] },
{ key: 'ui.search.question.timeline.clarify_need', fallback: 'Do you want causes, impacts, or follow-up events?', preferredModes: ['act', 'explain'] },
], 'clarify'),
};
const RELEASES_SELF_SERVE: SearchSelfServeDefinition = {
commonQuestions: withQuestionKind([
{ key: 'ui.search.question.releases.blocked', fallback: 'What blocked this promotion?', preferredModes: ['act', 'explain'] },
{ key: 'ui.search.question.releases.approvals', fallback: 'Which approvals are missing?', preferredModes: ['find'] },
{ key: 'ui.search.question.releases.next_step', fallback: 'What is the safest next step to ship?', preferredModes: ['act'] },
], 'page'),
clarifyingQuestions: withQuestionKind([
{ key: 'ui.search.question.releases.clarify_target', fallback: 'Which environment or release should I narrow this to?', preferredModes: ['find'] },
{ key: 'ui.search.question.releases.clarify_need', fallback: 'Do you want blockers, approvals, or policy impact?', preferredModes: ['act', 'explain'] },
], 'clarify'),
};
export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [
{
id: 'findings',
@@ -120,6 +259,7 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [
},
domain: 'findings',
searchSuggestions: FINDINGS_SEARCH_SUGGESTIONS,
selfServe: FINDINGS_SELF_SERVE,
},
{
id: 'findings-chat-detail',
@@ -137,6 +277,7 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [
descriptionFallback: 'Search vendor statements, affected ranges, and disposition evidence.',
},
domain: 'vex',
selfServe: VEX_SELF_SERVE,
},
{
id: 'policy',
@@ -150,6 +291,7 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [
domain: 'policy',
searchSuggestions: POLICY_SEARCH_SUGGESTIONS,
chatSuggestions: POLICY_CHAT_SUGGESTIONS,
selfServe: POLICY_SELF_SERVE,
},
{
id: 'doctor',
@@ -162,6 +304,7 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [
},
domain: 'knowledge',
searchSuggestions: DOCTOR_SEARCH_SUGGESTIONS,
selfServe: DOCTOR_SELF_SERVE,
},
{
id: 'graph',
@@ -173,6 +316,7 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [
descriptionFallback: 'Follow dependency and reachability paths across the platform.',
},
domain: 'graph',
selfServe: GRAPH_SELF_SERVE,
},
{
id: 'ops-memory',
@@ -184,6 +328,7 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [
descriptionFallback: 'Search recurring incidents, jobs, and learned operator runbooks.',
},
domain: 'ops_memory',
selfServe: OPS_MEMORY_SELF_SERVE,
},
{
id: 'timeline',
@@ -196,6 +341,7 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [
},
domain: 'timeline',
searchSuggestions: TIMELINE_SEARCH_SUGGESTIONS,
selfServe: TIMELINE_SELF_SERVE,
},
{
id: 'releases',
@@ -207,5 +353,6 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [
descriptionFallback: 'Investigate promotions, approvals, and blockers.',
},
searchSuggestions: RELEASES_SEARCH_SUGGESTIONS,
selfServe: RELEASES_SELF_SERVE,
},
] as const;

View File

@@ -48,6 +48,11 @@ type SearchSuggestionView = {
kind: 'page' | 'recent' | 'strategy';
preferredModes?: readonly SearchExperienceMode[];
};
type SearchQuestionView = {
query: string;
kind: 'page' | 'clarify' | 'recent';
preferredModes?: readonly SearchExperienceMode[];
};
type SearchContextPanelView = {
title: string;
description: string;
@@ -62,6 +67,20 @@ type RescueActionView = {
label: string;
description: string;
};
type SearchAnswerView = {
status: 'grounded' | 'clarify' | 'insufficient';
eyebrow: string;
title: string;
summary: string;
evidence: string;
citations: Array<{
key: string;
title: string;
}>;
questionLabel: string;
questions: SearchQuestionView[];
nextSearches: SearchSuggestionView[];
};
@Component({
selector: 'app-global-search',
@@ -134,6 +153,77 @@ type RescueActionView = {
</div>
</div>
@if (searchAnswer(); as answer) {
<section
class="search__answer"
[class.search__answer--clarify]="answer.status === 'clarify'"
[class.search__answer--insufficient]="answer.status === 'insufficient'"
[attr.data-answer-status]="answer.status"
>
<div class="search__answer-eyebrow">{{ answer.eyebrow }}</div>
<div class="search__answer-header">
<div>
<div class="search__answer-title">{{ answer.title }}</div>
<div class="search__answer-summary">{{ answer.summary }}</div>
</div>
<button
type="button"
class="search__answer-assistant"
data-answer-action="ask-ai"
(click)="openAssistantForAnswerPanel()"
>
{{ t('ui.search.answer.ask_ai', 'Ask AdvisoryAI') }}
</button>
</div>
<div class="search__answer-evidence">{{ answer.evidence }}</div>
@if (answer.citations.length > 0) {
<div class="search__answer-citations">
@for (citation of answer.citations; track citation.key) {
<span class="search__answer-citation" data-answer-citation>{{ citation.title }}</span>
}
</div>
}
@if (answer.questions.length > 0) {
<div class="search__answer-questions">
<div class="search__group-label">{{ answer.questionLabel }}</div>
<div class="search__question-chips">
@for (question of answer.questions; track question.query) {
<button
type="button"
class="search__question-chip"
[class.search__question-chip--clarify]="question.kind === 'clarify'"
[attr.data-answer-question]="question.kind"
(click)="applyQuestionQuery(question.query, answer.status === 'clarify' ? 'clarify' : 'answer')"
>
{{ question.query }}
</button>
}
</div>
</div>
}
@if (answer.nextSearches.length > 0) {
<div class="search__answer-next">
<div class="search__group-label">{{ t('ui.search.answer.next_searches', 'Search next') }}</div>
<div class="search__answer-next-actions">
@for (suggestion of answer.nextSearches; track suggestion.query) {
<button
type="button"
class="search__answer-next-search"
data-answer-next-search
(click)="applyAnswerNextSearch(suggestion.query)"
>
{{ suggestion.query }}
</button>
}
</div>
</div>
}
</section>
}
@if (isLoading()) {
<div class="search__loading">{{ t('ui.search.loading', 'Searching...') }}</div>
} @else if (query().trim().length >= 1 && cards().length === 0) {
@@ -286,6 +376,24 @@ type RescueActionView = {
</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) {
<button
type="button"
class="search__question-chip"
[attr.data-common-question]="question.kind"
(click)="applyQuestionQuery(question.query, 'common')"
>
{{ question.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">
@@ -673,6 +781,162 @@ type RescueActionView = {
color: var(--color-text-primary);
}
.search__questions {
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border-primary);
}
.search__question-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
}
.search__question-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);
color: var(--color-text-primary);
font-size: 0.75rem;
line-height: 1.2;
cursor: pointer;
transition: background-color 0.1s, border-color 0.1s, transform 0.1s;
}
.search__question-chip:hover {
border-color: var(--color-brand-primary, #1d4ed8);
background: var(--color-brand-primary-10, #eff6ff);
transform: translateY(-1px);
}
.search__question-chip--clarify {
border-style: dashed;
}
.search__answer {
margin: 0.625rem 0.75rem 0.5rem;
padding: 0.75rem;
border: 1px solid var(--color-brand-primary-20, #bfdbfe);
border-radius: var(--radius-sm);
background: linear-gradient(135deg, var(--color-brand-primary-10, #eff6ff) 0%, var(--color-surface-primary) 100%);
}
.search__answer--clarify {
border-color: #fbbf24;
background: linear-gradient(135deg, #fffbeb 0%, var(--color-surface-primary) 100%);
}
.search__answer--insufficient {
border-color: var(--color-border-primary);
background: linear-gradient(135deg, var(--color-surface-tertiary) 0%, var(--color-surface-primary) 100%);
}
.search__answer-eyebrow {
font-size: 0.6875rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.search__answer-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-top: 0.375rem;
}
.search__answer-title {
font-size: 0.9375rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.search__answer-summary {
margin-top: 0.25rem;
font-size: 0.75rem;
line-height: 1.45;
color: var(--color-text-primary);
}
.search__answer-assistant {
flex-shrink: 0;
padding: 0.375rem 0.625rem;
border: 1px solid var(--color-brand-primary-20, #bfdbfe);
border-radius: 999px;
background: var(--color-surface-primary);
color: var(--color-brand-primary, #1d4ed8);
font-size: 0.75rem;
cursor: pointer;
transition: background-color 0.1s, border-color 0.1s;
}
.search__answer-assistant:hover {
background: var(--color-brand-primary-10, #eff6ff);
border-color: var(--color-brand-primary, #1d4ed8);
}
.search__answer-evidence {
margin-top: 0.5rem;
font-size: 0.6875rem;
line-height: 1.35;
color: var(--color-text-muted);
}
.search__answer-citations {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.5rem;
}
.search__answer-citation {
display: inline-flex;
align-items: center;
padding: 0.1875rem 0.5rem;
border-radius: 999px;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-secondary);
font-size: 0.625rem;
color: var(--color-text-secondary);
}
.search__answer-questions,
.search__answer-next {
margin-top: 0.625rem;
}
.search__answer-next-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.25rem 0;
}
.search__answer-next-search {
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);
color: var(--color-text-secondary);
font-size: 0.6875rem;
cursor: pointer;
transition: background-color 0.1s, border-color 0.1s;
}
.search__answer-next-search:hover {
border-color: var(--color-brand-primary, #1d4ed8);
color: var(--color-text-primary);
background: var(--color-brand-primary-10, #eff6ff);
}
.search__suggestions {
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border-primary);
@@ -777,6 +1041,10 @@ type RescueActionView = {
grid-template-columns: 1fr;
}
.search__answer-header {
flex-direction: column;
}
.search__suggestion-cards {
grid-template-columns: 1fr;
}
@@ -978,6 +1246,12 @@ type RescueActionView = {
transition: none;
}
.search__question-chip,
.search__answer-assistant,
.search__answer-next-search {
transition: none;
}
.search__segment,
.search__scope-chip,
.search__rescue-card {
@@ -1185,6 +1459,107 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
}));
});
readonly commonQuestions = computed<SearchQuestionView[]>(() => {
const mode = this.experienceMode();
return [...this.ambientContext.getCommonQuestions(mode)]
.sort((left, right) =>
this.scoreQuestionForMode(right, mode) - this.scoreQuestionForMode(left, mode))
.map((question) => ({
query: this.i18n.tryT(question.key) ?? question.fallback,
kind: question.kind ?? 'page',
preferredModes: question.preferredModes,
}));
});
readonly clarifyingQuestions = computed<SearchQuestionView[]>(() => {
const mode = this.experienceMode();
return [...this.ambientContext.getClarifyingQuestions(mode)]
.sort((left, right) =>
this.scoreQuestionForMode(right, mode) - this.scoreQuestionForMode(left, mode))
.map((question) => ({
query: this.i18n.tryT(question.key) ?? question.fallback,
kind: question.kind ?? 'clarify',
preferredModes: question.preferredModes,
}));
});
readonly searchAnswer = computed<SearchAnswerView | null>(() => {
const query = this.query().trim();
if (!query || this.isLoading()) {
return null;
}
const response = this.searchResponse();
if (!response) {
return null;
}
const mode = this.experienceMode();
const pageLabel = this.searchContextPanel()?.title ?? this.t('ui.search.answer.context.default', 'Current page');
const modeLabel = this.experienceModeOptions().find((option) => option.id === mode)?.label ?? mode;
const eyebrow = `${pageLabel} | ${modeLabel}`;
const nextSearches = this.contextualSuggestions()
.filter((suggestion) => suggestion.query.trim().toLowerCase() !== query.toLowerCase())
.slice(0, 2);
const hasGroundedEvidence = response.cards.length > 0 || (response.synthesis?.sourceCount ?? 0) > 0;
if (hasGroundedEvidence) {
return {
status: 'grounded',
eyebrow,
title: this.answerTitleForMode(mode),
summary: this.buildGroundedAnswerSummary(response),
evidence: this.buildGroundedEvidenceLabel(response),
citations: this.buildAnswerCitations(response),
questionLabel: this.t('ui.search.answer.questions.follow_up', 'Ask next'),
questions: this.commonQuestions().slice(0, 3),
nextSearches,
};
}
const clarifyingQuestions = this.clarifyingQuestions();
if (clarifyingQuestions.length > 0) {
return {
status: 'clarify',
eyebrow,
title: this.t('ui.search.answer.title.clarify', 'Tighten the question'),
summary: this.t(
'ui.search.answer.summary.clarify',
'I could not form a grounded answer for "{query}" in {page}. Narrow the entity, time window, or scope.',
{ query, page: pageLabel },
),
evidence: this.t(
'ui.search.answer.evidence.clarify',
'No direct grounded answer was found in {scope}.',
{ scope: this.searchScopeLabel() },
),
citations: [],
questionLabel: this.t('ui.search.answer.questions.clarify', 'Clarify with one of these'),
questions: clarifyingQuestions,
nextSearches,
};
}
return {
status: 'insufficient',
eyebrow,
title: this.t('ui.search.answer.title.insufficient', 'Not enough evidence yet'),
summary: this.t(
'ui.search.answer.summary.insufficient',
'Search did not find enough evidence to answer "{query}" from the current context. Try a stronger entity, blocker, or time-bound question.',
{ query },
),
evidence: this.t(
'ui.search.answer.evidence.insufficient',
'Use a follow-up question, broaden the scope, or ask AdvisoryAI to help frame the next search.',
),
citations: [],
questionLabel: this.t('ui.search.answer.questions.retry', 'Try one of these questions'),
questions: this.commonQuestions().slice(0, 2),
nextSearches,
};
});
readonly rescueActions = computed<RescueActionView[]>(() => {
const query = this.query().trim();
const pageLabel = this.searchContextPanel()?.title ?? 'current page';
@@ -1598,6 +1973,29 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
this.searchTerms$.next(example.trim());
}
applyQuestionQuery(query: string, source: 'common' | 'answer' | 'clarify'): void {
const action = source === 'common'
? 'search_common_question'
: source === 'clarify'
? 'search_answer_clarify'
: 'search_answer_question';
this.recordAmbientAction(action, {
source: 'global_search_self_serve',
queryHint: query,
});
this.query.set(query);
this.searchTerms$.next(query.trim());
}
applyAnswerNextSearch(query: string): void {
this.recordAmbientAction('search_answer_next_search', {
source: 'global_search_self_serve',
queryHint: query,
});
this.query.set(query);
this.searchTerms$.next(query.trim());
}
applySuggestion(text: string): void {
this.recordAmbientAction('search_suggestion', {
source: 'global_search_did_you_mean',
@@ -1981,6 +2379,98 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
return score;
}
private scoreQuestionForMode(
question: {
kind?: 'page' | 'clarify' | 'recent';
preferredModes?: readonly SearchExperienceMode[];
},
mode: SearchExperienceMode,
): number {
let score = 0;
if (question.preferredModes?.includes(mode)) {
score += 6;
}
if (mode === 'act' && question.kind === 'recent') {
score += 2;
}
if (mode === 'explain' && question.kind === 'page') {
score += 1;
}
return score;
}
private answerTitleForMode(mode: SearchExperienceMode): string {
switch (mode) {
case 'explain':
return this.t('ui.search.answer.title.explain', 'What it means');
case 'act':
return this.t('ui.search.answer.title.act', 'Recommended next step');
default:
return this.t('ui.search.answer.title.find', 'What we found');
}
}
private buildGroundedAnswerSummary(response: UnifiedSearchResponse): string {
const synthesisSummary = response.synthesis?.summary?.trim();
if (synthesisSummary) {
return synthesisSummary;
}
const topCard = response.cards[0];
if (!topCard) {
return this.t('ui.search.answer.summary.grounded.default', 'Relevant evidence was found for this query.');
}
if (this.experienceMode() === 'act') {
return this.t(
'ui.search.answer.summary.grounded.act',
'{title} is the strongest next lead. Use the result actions below to inspect or act.',
{ title: topCard.title },
);
}
return topCard.snippet?.trim() || topCard.title;
}
private buildGroundedEvidenceLabel(response: UnifiedSearchResponse): string {
const sourceCount = Math.max(response.synthesis?.sourceCount ?? 0, response.cards.length);
const domains = response.synthesis?.domainsCovered?.length
? response.synthesis.domainsCovered
: [...new Set(response.cards.map((card) => DOMAIN_LABELS[card.domain] ?? card.domain))];
const domainLabel = domains.slice(0, 3).join(', ') || this.t('ui.search.answer.domain.default', 'current scope');
return this.t(
'ui.search.answer.evidence.grounded',
'Grounded in {count} source(s) across {domains}.',
{ count: sourceCount, domains: domainLabel },
);
}
private buildAnswerCitations(response: UnifiedSearchResponse): Array<{ key: string; title: string }> {
const citations = response.synthesis?.citations ?? [];
if (citations.length > 0) {
return citations
.map((citation) => {
const matchingCard = response.cards.find((card) => card.entityKey === citation.entityKey);
return {
key: citation.entityKey,
title: matchingCard?.title ?? citation.title,
};
})
.slice(0, 3);
}
return response.cards
.slice(0, 3)
.map((card) => ({
key: card.entityKey,
title: card.title,
}));
}
private buildModeAwareAlternativeQuery(): string | null {
const currentQuery = this.query().trim().toLowerCase();
const mode = this.experienceMode();
@@ -2030,6 +2520,32 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
});
}
private openAssistantForAnswerPanel(): void {
const query = this.query().trim();
const pageLabel = this.searchContextPanel()?.title ?? 'current page';
const directive = this.searchExperienceMode.definition().assistantDirective;
const answer = this.searchAnswer();
const suggestedPrompt = answer?.status === 'grounded'
? `I searched for "${query}" on ${pageLabel}. ${directive} Expand the grounded answer, explain the evidence, and tell me the safest next step.`
: `I searched for "${query}" on ${pageLabel}. ${directive} Help me clarify the query, explain what evidence is missing, and propose the best next search.`;
this.recordAmbientAction('search_answer_to_chat', {
source: 'global_search_answer_panel',
queryHint: query || pageLabel,
});
this.searchChatContext.setSearchToChat({
query: query || pageLabel,
entityCards: this.cards(),
synthesis: this.synthesis(),
suggestedPrompt,
mode: this.experienceMode(),
});
this.closeResults();
void this.router.navigate(['/security/triage'], {
queryParams: { openChat: 'true', q: query || pageLabel },
});
}
private resolveSuggestionReason(suggestion: {
reasonKey?: string;
reasonFallback?: string;

View File

@@ -34,6 +34,15 @@ describe('AmbientContextService', () => {
expect(keys).toContain('ui.search.suggestion.findings.unresolved');
});
it('returns page-owned common questions for the active route', () => {
const service = TestBed.inject(AmbientContextService);
const questions = service.getCommonQuestions('explain').map((item) => item.fallback);
expect(questions).toContain('Why is this exploitable in my environment?');
expect(questions).toContain('What evidence blocks this release?');
expect(questions).toContain('What is the safest remediation path?');
});
it('updates search and chat suggestion sets when route changes', () => {
const service = TestBed.inject(AmbientContextService);
@@ -98,6 +107,23 @@ describe('AmbientContextService', () => {
expect(followUps).toContain('follow up: CVE-2024-21626');
});
it('builds a recent-action question for the active mode', () => {
const service = TestBed.inject(AmbientContextService);
service.recordAction({
action: 'search_result_open',
queryHint: 'CVE-2024-21626',
domain: 'findings',
});
const questions = service.getCommonQuestions('act');
expect(questions[0]).toEqual(jasmine.objectContaining({
key: 'ui.search.question.recent_action.default',
fallback: 'What should I do next for CVE-2024-21626?',
kind: 'recent',
preferredModes: ['act'],
}));
});
it('expires stale last-action suggestions after TTL', () => {
const service = TestBed.inject(AmbientContextService);
service.recordAction({
@@ -167,4 +193,15 @@ describe('AmbientContextService', () => {
}),
]));
});
it('returns route-specific clarifying questions after navigation changes', () => {
const service = TestBed.inject(AmbientContextService);
router.url = '/ops/policy';
events.next(new NavigationEnd(1, '/ops/policy', '/ops/policy'));
const questions = service.getClarifyingQuestions('act').map((item) => item.fallback);
expect(questions).toContain('Which rule, environment, or control should I narrow this to?');
expect(questions).toContain('Do you want recent failures, exceptions, or promotion impact?');
});
});

View File

@@ -57,6 +57,8 @@ describe('GlobalSearchComponent', () => {
'buildContextFilter',
'getSearchContextPanel',
'getSearchSuggestions',
'getCommonQuestions',
'getClarifyingQuestions',
'buildAmbientContext',
'recordAction',
]) as jasmine.SpyObj<AmbientContextService>;
@@ -101,6 +103,40 @@ describe('GlobalSearchComponent', () => {
kind: 'page',
},
]);
ambientContext.getCommonQuestions.and.returnValue([
{
key: 'ui.search.question.findings.exploitable',
fallback: 'Why is this exploitable in my environment?',
kind: 'page',
preferredModes: ['explain'],
},
{
key: 'ui.search.question.findings.release_blocker',
fallback: 'What evidence blocks this release?',
kind: 'page',
preferredModes: ['act', 'explain'],
},
{
key: 'ui.search.question.findings.remediation',
fallback: 'What is the safest remediation path?',
kind: 'page',
preferredModes: ['act'],
},
]);
ambientContext.getClarifyingQuestions.and.returnValue([
{
key: 'ui.search.question.findings.clarify_target',
fallback: 'Which CVE, workload, or package should I narrow this to?',
kind: 'clarify',
preferredModes: ['find', 'explain'],
},
{
key: 'ui.search.question.findings.clarify_scope',
fallback: 'Should I focus on reachable, production, or unresolved findings?',
kind: 'clarify',
preferredModes: ['find', 'act'],
},
]);
ambientContext.buildAmbientContext.and.returnValue({
currentRoute: '/security/triage',
recentSearches: [],
@@ -176,6 +212,19 @@ describe('GlobalSearchComponent', () => {
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());
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?');
});
it('queries unified search for one-character query terms', async () => {
component.onFocus();
component.onQueryChange('a');
@@ -390,6 +439,65 @@ describe('GlobalSearchComponent', () => {
expect(rescueCards.length).toBe(4);
});
it('renders a grounded answer panel before search results', async () => {
searchClient.search.and.returnValue(of({
query: 'critical findings',
topK: 10,
cards: [createCard('findings', '/triage/findings/fnd-101')],
synthesis: {
summary: 'One critical finding matched the current page context.',
template: 'finding_overview',
confidence: 'high',
sourceCount: 2,
domainsCovered: ['Findings', 'Policy'],
citations: [
{
index: 1,
entityKey: 'findings:sample',
title: 'findings sample',
},
],
},
diagnostics: {
ftsMatches: 1,
vectorMatches: 0,
entityCardCount: 1,
durationMs: 5,
usedVector: false,
mode: 'fts-only',
},
}));
component.onFocus();
component.onQueryChange('critical findings');
await waitForDebounce();
fixture.detectChanges();
const answerPanel = fixture.nativeElement.querySelector('[data-answer-status="grounded"]') as HTMLElement | null;
expect(answerPanel).not.toBeNull();
expect(answerPanel?.textContent).toContain('What we found');
expect(answerPanel?.textContent).toContain('One critical finding matched the current page context.');
expect(answerPanel?.textContent).toContain('Grounded in 2 source(s) across Findings, Policy.');
expect(answerPanel?.textContent).toContain('findings sample');
});
it('renders a clarify answer panel when no grounded evidence is found', async () => {
component.onFocus();
component.onQueryChange('mystery issue');
await waitForDebounce();
fixture.detectChanges();
const answerPanel = fixture.nativeElement.querySelector('[data-answer-status="clarify"]') as HTMLElement | null;
const answerQuestions = Array.from(
fixture.nativeElement.querySelectorAll('[data-answer-question]') as NodeListOf<HTMLButtonElement>,
).map((node) => node.textContent?.trim());
expect(answerPanel).not.toBeNull();
expect(answerPanel?.textContent).toContain('Tighten the question');
expect(answerQuestions).toContain('Which CVE, workload, or package should I narrow this to?');
expect(answerQuestions).toContain('Should I focus on reachable, production, or unresolved findings?');
});
it('retries the active query globally when scope rescue toggles off page filtering', async () => {
ambientContext.buildContextFilter.and.returnValue({ domains: ['findings'] } as any);

View File

@@ -0,0 +1,239 @@
import { expect, test, type Page } from '@playwright/test';
import {
buildResponse,
emptyResponse,
findingCard,
setupAuthenticatedSession,
setupBasicMocks,
typeInSearch,
waitForEntityCards,
waitForResults,
} from './unified-search-fixtures';
const groundedFindingResponse = {
query: 'critical findings',
topK: 10,
cards: [
findingCard({
cveId: 'CVE-2024-21626',
title: 'CVE-2024-21626 in api-gateway',
snippet: 'Reachable critical vulnerability detected in the current production path.',
severity: 'critical',
}),
],
synthesis: {
summary: 'A reachable critical finding is blocking the current workflow and policy review is warranted.',
template: 'finding_overview',
confidence: 'high',
sourceCount: 2,
domainsCovered: ['Findings', 'Policy'],
citations: [
{
index: 1,
entityKey: 'cve:CVE-2024-21626',
title: 'CVE-2024-21626 in api-gateway',
},
],
},
diagnostics: {
ftsMatches: 3,
vectorMatches: 1,
entityCardCount: 1,
durationMs: 33,
usedVector: true,
mode: 'hybrid',
},
};
const narrowedFindingResponse = buildResponse(
'Which CVE, workload, or package should I narrow this to?',
[
findingCard({
cveId: 'CVE-2023-38545',
title: 'CVE-2023-38545 in edge-router',
snippet: 'A narrowed finding result is now grounded and ready for review.',
severity: 'high',
}),
],
{
summary: 'Narrowing the question exposed a grounded finding answer.',
template: 'finding_overview',
confidence: 'high',
sourceCount: 1,
domainsCovered: ['Findings'],
},
);
test.describe('Unified Search - Self-Serve Answer Panel', () => {
test.beforeEach(async ({ page }) => {
await setupBasicMocks(page);
await setupAuthenticatedSession(page);
});
test('shows page-owned common questions and a grounded answer panel', async ({ page }) => {
await mockSearchResponses(page, (query) =>
query.includes('critical findings') ? groundedFindingResponse : emptyResponse(query));
await page.goto('/security/triage');
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
await page.locator('app-global-search input[type="text"]').focus();
await waitForResults(page);
await expect(page.locator('[data-common-question]')).toContainText([
'Why is this exploitable in my environment?',
'What evidence blocks this release?',
'What is the safest remediation path?',
]);
await typeInSearch(page, 'critical findings');
await waitForResults(page);
await waitForEntityCards(page, 1);
const answerPanel = page.locator('[data-answer-status="grounded"]');
await expect(answerPanel).toBeVisible();
await expect(answerPanel).toContainText('What we found');
await expect(answerPanel).toContainText('A reachable critical finding is blocking the current workflow and policy review is warranted.');
await expect(answerPanel).toContainText('Grounded in 2 source(s) across Findings, Policy.');
await expect(answerPanel.locator('[data-answer-citation]')).toContainText(['CVE-2024-21626 in api-gateway']);
});
test('uses clarify questions to rerun search and recover a grounded answer', async ({ page }) => {
await mockSearchResponses(page, (query) => {
if (query.includes('which cve, workload, or package should i narrow this to?')) {
return narrowedFindingResponse;
}
if (query.includes('mystery issue')) {
return emptyResponse('mystery issue');
}
return emptyResponse(query);
});
await page.goto('/security/triage');
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
await typeInSearch(page, 'mystery issue');
await waitForResults(page);
await expect(page.locator('[data-answer-status="clarify"]')).toBeVisible();
await page.getByRole('button', { name: 'Which CVE, workload, or package should I narrow this to?' }).click();
await waitForResults(page);
await waitForEntityCards(page, 1);
await expect(page.locator('app-global-search input[type="text"]')).toHaveValue(
'Which CVE, workload, or package should I narrow this to?',
);
await expect(page.locator('[data-answer-status="grounded"]')).toContainText(
'Narrowing the question exposed a grounded finding answer.',
);
});
test('opens AdvisoryAI from the answer panel with mode-aware context', async ({ page }) => {
await mockSearchResponses(page, (query) =>
query.includes('critical findings') ? groundedFindingResponse : emptyResponse(query));
await mockChatConversation(page, {
content: 'I can expand the grounded answer, explain the evidence, and recommend the safest next step.',
citations: [{ type: 'finding', path: 'CVE-2024-21626', verified: true }],
groundingScore: 0.95,
});
await page.goto('/security/triage');
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
await page.locator('app-global-search input[type="text"]').focus();
await waitForResults(page);
await page.locator('.search__experience-bar').getByRole('button', { name: 'Act' }).click();
await typeInSearch(page, 'critical findings');
await waitForResults(page);
await waitForEntityCards(page, 1);
await page.locator('[data-answer-action="ask-ai"]').click();
await expect(page.locator('.assistant-drawer')).toBeVisible({ timeout: 10_000 });
await expect(page.locator('.chat-mode-btn--active')).toHaveText(/Act/i);
await expect(page.locator('.chat-message.user .message-body').first()).toContainText(/Expand the grounded answer/i);
await expect(page.locator('.chat-message.user .message-body').first()).toContainText(/critical findings/i);
});
});
async function mockSearchResponses(
page: Page,
resolve: (normalizedQuery: string) => unknown,
): Promise<void> {
await page.route('**/search/query**', async (route) => {
const body = route.request().postDataJSON() as Record<string, unknown>;
const query = String(body['q'] ?? '').toLowerCase();
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(resolve(query)),
});
});
}
async function mockChatConversation(
page: Page,
response: {
content: string;
citations: Array<{ type: string; path: string; verified: boolean }>;
groundingScore: number;
},
): Promise<void> {
await page.route('**/api/v1/advisory-ai/conversations', async (route) => {
if (route.request().method() !== 'POST') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([]),
});
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
conversationId: 'conv-self-serve-1',
tenantId: 'test-tenant',
userId: 'tester',
context: {},
turns: [],
createdAt: '2026-03-07T00:00:00.000Z',
updatedAt: '2026-03-07T00:00:00.000Z',
}),
});
});
await page.route('**/api/v1/advisory-ai/conversations/*/turns', async (route) => {
if (route.request().method() !== 'POST') {
return route.continue();
}
const events = [
'event: progress',
'data: {"stage":"searching"}',
'',
'event: token',
`data: ${JSON.stringify({ content: response.content })}`,
'',
...response.citations.flatMap((citation) => ([
'event: citation',
`data: ${JSON.stringify(citation)}`,
'',
])),
'event: done',
`data: ${JSON.stringify({ turnId: 'turn-self-serve-1', groundingScore: response.groundingScore })}`,
'',
].join('\n');
return route.fulfill({
status: 200,
headers: {
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache',
},
body: events,
});
});
}