Simplify the primary search surface

This commit is contained in:
master
2026-03-07 20:58:52 +02:00
parent 8ee5dcf420
commit 437d26c47c
14 changed files with 276 additions and 327 deletions

View File

@@ -19,7 +19,7 @@
## Delivery Tracker
### FE-ZL2-001 - Remove remaining FE mode dependencies
Status: TODO
Status: DONE
Dependency: none
Owners: Developer
Task description:
@@ -27,12 +27,12 @@ Task description:
- Eliminate mode-driven prompt helpers and chip/question filtering from the shared search context contracts and FE composition paths.
Completion criteria:
- [ ] Search and assistant FE code no longer depend on `SearchExperienceModeService`.
- [ ] Page-owned search contracts do not use `preferredModes`.
- [ ] Search-to-chat prompts derive from query, route, evidence, and last actions only.
- [x] Search and assistant FE code no longer depend on `SearchExperienceModeService`.
- [x] Page-owned search contracts do not use `preferredModes`.
- [x] Search-to-chat prompts derive from query, route, evidence, and last actions only.
### FE-ZL2-002 - Tighten the primary search surface
Status: TODO
Status: DONE
Dependency: FE-ZL2-001
Owners: Developer
Task description:
@@ -40,12 +40,12 @@ Task description:
- Remove residual explanatory clutter that teaches the system instead of helping the user search.
Completion criteria:
- [ ] `Did you mean` renders as an input-adjacent correction cue.
- [ ] Empty-state starters stay concise and executable with no ranking-mechanics copy.
- [ ] Result labels use plain operator language for in-scope and spillover sections.
- [x] `Did you mean` renders as an input-adjacent correction cue.
- [x] Empty-state starters stay concise and executable with no ranking-mechanics copy.
- [x] Result labels use plain operator language for in-scope and spillover sections.
### FE-ZL2-003 - Migrate recent history to a success-only contract
Status: TODO
Status: DONE
Dependency: FE-ZL2-002
Owners: Developer
Task description:
@@ -53,12 +53,12 @@ Task description:
- Drop old failed or unknown legacy entries on load so history reflects only searches that actually worked.
Completion criteria:
- [ ] Local recent history stores structured successful entries rather than bare strings.
- [ ] Legacy entries are ignored unless they can be confirmed from server history as successful.
- [ ] No-result searches never reappear after reload.
- [x] Local recent history stores structured successful entries rather than bare strings.
- [x] Legacy entries are ignored unless they can be confirmed from server history as successful.
- [x] No-result searches never reappear after reload.
### FE-ZL2-004 - Verify the simplified search surface
Status: TODO
Status: DONE
Dependency: FE-ZL2-003
Owners: Developer, Test Automation
Task description:
@@ -66,14 +66,15 @@ Task description:
- Tests must prove the removal of FE modes, input-adjacent correction cues, and success-only history behavior.
Completion criteria:
- [ ] Angular tests cover history migration and no-mode FE composition.
- [ ] Playwright covers input correction placement, success-only history, and assistant launch from the field.
- [ ] No route-jump or visible scope/mode controls remain in covered flows.
- [x] Angular tests cover history migration and no-mode FE composition.
- [x] Playwright covers input correction placement, success-only history, and assistant launch from the field.
- [x] No route-jump or visible scope/mode controls remain in covered flows.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-07 | Sprint created for the FE half of the operator correction pass on global search. | Project Manager |
| 2026-03-07 | Removed the FE mode contract (`SearchExperienceModeService`, `preferredModes`), migrated successful history to `stella-successful-searches-v3`, simplified overflow/correction copy, and verified with targeted Angular tests plus deterministic and live Playwright suggestion suites. Commands: `npm test -- --include src/tests/context/ambient-context.service.spec.ts --include src/tests/global_search/global-search.component.spec.ts`; `npx playwright test tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts tests/e2e/unified-search-experience-quality.e2e.spec.ts tests/e2e/unified-search-self-serve-answer-panel.e2e.spec.ts --config playwright.config.ts`; `LIVE_ADVISORYAI_SEARCH_BASE_URL=http://127.0.0.1:10451 npx playwright test tests/e2e/unified-search-contextual-suggestions.live.e2e.spec.ts --config playwright.config.ts`. Results: `32/32` Angular tests passed, `14/14` deterministic Playwright tests passed, and `5/5` live Doctor suggestion tests passed. | Developer / Test Automation |
## Decisions & Risks
- Decision: search surface simplification is not cosmetic; it removes product concepts the operator should never need to learn.

View File

@@ -21,13 +21,12 @@
- `key` / `fallback` for the executable query text
- `reasonKey` / `reasonFallback` for the visible "why this suggestion" line
- optional `kind` (`page`, `recent`, `strategy`) when page teams need non-default styling/priority
- optional `preferredModes` (`find`, `explain`, `act`) when a chip should rank higher for a specific operator intent
- Suggestion arrays must stay deterministic and bounded:
- at most 3 base chips per page context
- short, action-oriented text
- no tenant/user secrets in fallback text
- keep the query label distinct from rationale text so suggestion clicks always submit the intended query only
- fallback copy should still make sense after mode-aware reordering; do not encode mode-specific rationale into the raw query text
- fallback copy should stand on its own; do not encode hidden ranking or workflow instructions into the raw query text
## Source of truth
- Contract and registry:
@@ -48,12 +47,11 @@
- Generated chips must also expose rationale metadata:
- `recent` chips explain they came from last-page actions
- `strategy` chips explain they came from recent intent on the same page scope
- Mode-aware ranking:
- the shared `SearchExperienceModeService` owns the active operator mode
- chips marked with `preferredModes` are ranked ahead of otherwise-equal chips for that mode
- page teams should use this sparingly to express clear intent differences, not to create large per-mode chip forks
- Ranking behavior:
- chip order is driven by page context, recent scoped actions, deterministic rotation, and backend viability
- page teams should not create hidden forks that assume the user chose an internal search mode
- Search-surface control rule:
- buttons inside the search surface (mode switch, scope toggle, rescue cards, filters) are part of the same command workspace
- buttons inside the search surface (assistant launcher, correction suggestions, starter chips, answer follow-ups, card actions) are part of the same command workspace
- focus transitions into those controls must not collapse the search panel
- teams adding new controls inside the panel must preserve this containment rule in tests
@@ -61,7 +59,7 @@
1. Add/adjust a context in `SEARCH_CONTEXT_DEFINITIONS`.
2. Ensure page component exposes the same `searchContextId` (implements `SearchContextComponent`).
3. Define or update `presentation` copy for the context rail.
4. Add or update `reasonFallback` text and any `preferredModes` metadata for each base chip.
4. Add or update `reasonFallback` text for each base chip.
5. Add/adjust unit tests in `ambient-context.service.spec.ts`.
6. Add/adjust Playwright tests for route chips + action-driven chips.
7. If the page adds custom controls inside the search panel, add focus-containment coverage so those controls do not dismiss the panel.

View File

@@ -14,7 +14,6 @@
- Question entries must provide:
- `key` / `fallback` for the executable question text
- optional `kind` (`page`, `clarify`, `recent`)
- optional `preferredModes` (`find`, `explain`, `act`)
- Question arrays must stay deterministic and bounded:
- at most 3 base common questions per page
- at most 2 base clarifying questions per page
@@ -32,15 +31,14 @@
## Runtime behavior
- Empty-state search uses `commonQuestions[]` for page-owned "Common questions".
- The active mode (`Find`, `Explain`, `Act`) reorders questions via `preferredModes`.
- A recent-action question may be prepended from the latest meaningful page action:
- `Find` -> related discovery question
- `Explain` -> why-it-matters question
- `Act` -> what-to-do-next question
- opened/used result -> why-it-matters question
- search/chat expansion -> what-to-verify-next question
- fallback -> related follow-up question
- Result-state answer panels use:
- `commonQuestions[]` as follow-up questions when grounded evidence exists
- `clarifyingQuestions[]` when no grounded answer exists
- "Search next" remains driven by contextual chip logic so pages do not need to define a second parallel action system.
- "Related searches" remains driven by contextual chip logic so pages do not need to define a second parallel action system.
## Source of truth
- Page registry and interfaces:

View File

@@ -110,7 +110,8 @@
- 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 from the corrective phases: backend overflow is now narrow enough that clear in-scope winners suppress out-of-scope spillover, blended summaries only appear for genuinely close evidence clusters, and `SearchTelemetryEnabled` cleanly disables analytics/feedback/sink emission without affecting retrieval or history.
- Still pending from the corrective phases: removal of hidden FE mode dependencies, a structured success-only history contract that purges failed legacy entries on load, stricter suggestion/corpus readiness gating across more routes, and broader live-page matrices.
- Implemented from the operator-correction pass: FE search contracts no longer depend on hidden `Find / Explain / Act` metadata, starter chips wait for backend viability before rendering, `Did you mean` is the first in-panel cue under the search field, and successful recent history now uses a structured `stella-successful-searches-v3` contract that ignores legacy bare-string entries on load.
- Still pending from the corrective phases: stricter backend viability states across more domains, broader live-page matrices, and explicit client-side telemetry opt-out.
## Execution phases - operator correction pass
### Phase 1 - Search-first primary surface cleanup

View File

@@ -18,7 +18,6 @@ import {
type SearchQuestionChip,
type SearchSuggestionChip,
} from './search-context.registry';
import type { SearchExperienceMode } from './search-experience-mode.service';
export type ContextSuggestion = SearchSuggestionChip;
export type ContextQuestion = SearchQuestionChip;
@@ -130,7 +129,7 @@ export class AmbientContextService {
return deduped.slice(0, 4);
}
getCommonQuestions(mode: SearchExperienceMode): readonly ContextQuestion[] {
getCommonQuestions(): readonly ContextQuestion[] {
const route = this.routeUrl();
const scopeKey = this.routeScope(route);
const context = this.findContext(route, (candidate) =>
@@ -140,8 +139,8 @@ export class AmbientContextService {
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}`);
const recentQuestion = this.buildRecentActionQuestion(recentActions[0] ?? null);
const rotatedQuestions = this.rotateEntries(baseQuestions, `${scopeKey}|common`);
return [recentQuestion, ...rotatedQuestions]
.filter((entry): entry is ContextQuestion => entry !== null)
@@ -151,7 +150,7 @@ export class AmbientContextService {
.slice(0, 4);
}
getClarifyingQuestions(mode: SearchExperienceMode): readonly ContextQuestion[] {
getClarifyingQuestions(): readonly ContextQuestion[] {
const route = this.routeUrl();
const scopeKey = this.routeScope(route);
const context = this.findContext(route, (candidate) =>
@@ -160,7 +159,7 @@ export class AmbientContextService {
);
const baseQuestions = context?.selfServe?.clarifyingQuestions ?? DEFAULT_CLARIFYING_QUESTIONS;
return this.rotateEntries(baseQuestions, `${scopeKey}|clarify|${mode}`).slice(0, 3);
return this.rotateEntries(baseQuestions, `${scopeKey}|clarify`).slice(0, 3);
}
getChatSuggestions(): readonly ContextSuggestion[] {
@@ -259,7 +258,6 @@ export class AmbientContextService {
reasonKey: 'ui.search.suggestion.reason.last_action',
reasonFallback: 'Based on your last actions on this page.',
kind: 'recent',
preferredModes: ['find', 'explain', 'act'],
});
if (suggestions.length >= maxCount) {
@@ -409,13 +407,11 @@ export class AmbientContextService {
reasonKey: 'ui.search.suggestion.reason.strategy',
reasonFallback: 'Generated from the recent intent on this page.',
kind: 'strategy',
preferredModes: ['explain', 'act'],
};
}
private buildRecentActionQuestion(
action: UnifiedSearchAmbientAction | null,
mode: SearchExperienceMode,
): ContextQuestion | null {
if (!action) {
return null;
@@ -426,18 +422,26 @@ export class AmbientContextService {
return null;
}
const normalizedAction = action.action.trim().toLowerCase();
let fallback = `What else is related to ${hint}?`;
if (mode === 'explain') {
if (
normalizedAction === 'chat_search_for_more'
|| normalizedAction === 'chat_search_related'
|| normalizedAction === 'search_to_chat'
|| normalizedAction === 'search_to_chat_synthesis'
) {
fallback = `What should I verify next for ${hint}?`;
} else if (
normalizedAction === 'search_result_open'
|| normalizedAction === 'search_result_action'
) {
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],
};
}

View File

@@ -1,5 +1,4 @@
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';
@@ -10,14 +9,12 @@ export interface SearchSuggestionChip {
reasonKey?: string;
reasonFallback?: string;
kind?: SearchSuggestionKind;
preferredModes?: readonly SearchExperienceMode[];
}
export interface SearchQuestionChip {
key: string;
fallback: string;
kind?: SearchQuestionKind;
preferredModes?: readonly SearchExperienceMode[];
}
export interface SearchSelfServeDefinition {
@@ -76,9 +73,9 @@ function withQuestionKind(
}
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'] },
{ key: 'ui.search.suggestion.default.critical', fallback: 'Show critical findings', preferredModes: ['find'] },
{ key: 'ui.search.suggestion.default.deploy', fallback: 'How do I deploy?' },
{ key: 'ui.search.suggestion.default.vex', fallback: 'What is a VEX statement?' },
{ key: 'ui.search.suggestion.default.critical', fallback: 'Show critical findings' },
], 'ui.search.suggestion.reason.default', 'Useful starting points across Stella Ops.');
export const DEFAULT_CHAT_SUGGESTIONS: readonly SearchSuggestionChip[] = [
@@ -89,52 +86,50 @@ export const DEFAULT_CHAT_SUGGESTIONS: readonly SearchSuggestionChip[] = [
];
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'] },
{ key: 'ui.search.question.default.changed', fallback: 'What changed most recently?' },
{ key: 'ui.search.question.default.evidence', fallback: 'Show me the strongest evidence' },
{ key: 'ui.search.question.default.next_step', fallback: 'What should I inspect next?' },
], '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'],
fallback: 'What exact blocker or symptom should I narrow this to?',
},
], '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'] },
{ key: 'ui.search.suggestion.findings.unresolved', fallback: 'unresolved CVEs', preferredModes: ['find'] },
{ key: 'ui.search.suggestion.findings.critical', fallback: 'critical findings' },
{ key: 'ui.search.suggestion.findings.reachable', fallback: 'reachable vulnerabilities' },
{ key: 'ui.search.suggestion.findings.unresolved', fallback: 'unresolved CVEs' },
], 'ui.search.suggestion.reason.findings', 'Common triage pivots for the current findings workspace.');
const POLICY_SEARCH_SUGGESTIONS: readonly SearchSuggestionChip[] = withReason([
{ key: 'ui.search.suggestion.policy.failing_gates', fallback: 'failing policy gates', preferredModes: ['act', 'explain'] },
{ key: 'ui.search.suggestion.policy.production_deny', fallback: 'production deny rules', preferredModes: ['act'] },
{ key: 'ui.search.suggestion.policy.exceptions', fallback: 'policy exceptions', preferredModes: ['act'] },
{ key: 'ui.search.suggestion.policy.failing_gates', fallback: 'failing policy gates' },
{ key: 'ui.search.suggestion.policy.production_deny', fallback: 'production deny rules' },
{ key: 'ui.search.suggestion.policy.exceptions', fallback: 'policy exceptions' },
], 'ui.search.suggestion.reason.policy', 'High-signal policy investigations for this page.');
const DOCTOR_SEARCH_SUGGESTIONS: readonly SearchSuggestionChip[] = withReason([
{ key: 'ui.search.suggestion.doctor.database', fallback: 'database connectivity', preferredModes: ['act'] },
{ key: 'ui.search.suggestion.doctor.disk', fallback: 'disk space', preferredModes: ['act'] },
{ key: 'ui.search.suggestion.doctor.oidc', fallback: 'OIDC readiness', preferredModes: ['explain', 'act'] },
{ key: 'ui.search.suggestion.doctor.database', fallback: 'database connectivity' },
{ key: 'ui.search.suggestion.doctor.disk', fallback: 'disk space' },
{ key: 'ui.search.suggestion.doctor.oidc', fallback: 'OIDC readiness' },
], 'ui.search.suggestion.reason.doctor', 'Fast operational checks for the current health view.');
const TIMELINE_SEARCH_SUGGESTIONS: readonly SearchSuggestionChip[] = withReason([
{ key: 'ui.search.suggestion.timeline.failed_deployments', fallback: 'failed deployments', preferredModes: ['act'] },
{ key: 'ui.search.suggestion.timeline.recent_promotions', fallback: 'recent promotions', preferredModes: ['find'] },
{ key: 'ui.search.suggestion.timeline.release_history', fallback: 'release history', preferredModes: ['explain'] },
{ key: 'ui.search.suggestion.timeline.failed_deployments', fallback: 'failed deployments' },
{ key: 'ui.search.suggestion.timeline.recent_promotions', fallback: 'recent promotions' },
{ key: 'ui.search.suggestion.timeline.release_history', fallback: 'release history' },
], 'ui.search.suggestion.reason.timeline', 'Frequent pivots for promotion and incident analysis.');
const RELEASES_SEARCH_SUGGESTIONS: readonly SearchSuggestionChip[] = withReason([
{ key: 'ui.search.suggestion.releases.pending_approvals', fallback: 'pending approvals', preferredModes: ['act'] },
{ key: 'ui.search.suggestion.releases.blocked_releases', fallback: 'blocked releases', preferredModes: ['act'] },
{ key: 'ui.search.suggestion.releases.environment_status', fallback: 'environment status', preferredModes: ['find'] },
{ key: 'ui.search.suggestion.releases.pending_approvals', fallback: 'pending approvals' },
{ key: 'ui.search.suggestion.releases.blocked_releases', fallback: 'blocked releases' },
{ key: 'ui.search.suggestion.releases.environment_status', fallback: 'environment status' },
], 'ui.search.suggestion.reason.releases', 'Common release-investigation pivots for this workflow.');
const FINDINGS_CHAT_SUGGESTIONS: readonly SearchSuggestionChip[] = [
@@ -153,97 +148,97 @@ const POLICY_CHAT_SUGGESTIONS: readonly SearchSuggestionChip[] = [
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'] },
{ key: 'ui.search.question.findings.exploitable', fallback: 'Why is this exploitable in my environment?' },
{ key: 'ui.search.question.findings.release_blocker', fallback: 'What evidence blocks this release?' },
{ key: 'ui.search.question.findings.remediation', fallback: 'What is the safest remediation path?' },
], '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'] },
{ key: 'ui.search.question.findings.clarify_target', fallback: 'Which CVE, workload, or package should I narrow this to?' },
{ key: 'ui.search.question.findings.clarify_scope', fallback: 'What part of this finding matters most right now?' },
], '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'] },
{ key: 'ui.search.question.vex.why_not_affected', fallback: 'Why is this marked not affected?' },
{ key: 'ui.search.question.vex.covered_components', fallback: 'Which components are covered by this VEX?' },
{ key: 'ui.search.question.vex.conflicting_evidence', fallback: 'What evidence conflicts with this VEX?' },
], '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'] },
{ key: 'ui.search.question.vex.clarify_statement', fallback: 'Which statement, component, or product range should I narrow this to?' },
{ key: 'ui.search.question.vex.clarify_need', fallback: 'Do you want exploitability meaning, coverage, or conflict evidence?' },
], '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'] },
{ key: 'ui.search.question.policy.why_failing', fallback: 'Why is this gate failing?' },
{ key: 'ui.search.question.policy.impacted_findings', fallback: 'What findings are impacted by this rule?' },
{ key: 'ui.search.question.policy.exception', fallback: 'What is the safest exception path?' },
], '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'] },
{ key: 'ui.search.question.policy.clarify_target', fallback: 'Which rule, environment, or control should I narrow this to?' },
{ key: 'ui.search.question.policy.clarify_need', fallback: 'Do you want recent failures, exceptions, or promotion impact?' },
], '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'] },
{ key: 'ui.search.question.doctor.blocking_check', fallback: 'Which failing check is blocking release?' },
{ key: 'ui.search.question.doctor.verify_fix', fallback: 'How do I verify the fix safely?' },
{ key: 'ui.search.question.doctor.changed', fallback: 'What changed before this health issue?' },
], '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'] },
{ key: 'ui.search.question.doctor.clarify_check', fallback: 'Which check or symptom should I narrow this to?' },
{ key: 'ui.search.question.doctor.clarify_need', fallback: 'Do you want diagnosis, remediation, or verification steps?' },
], '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'] },
{ key: 'ui.search.question.graph.path', fallback: 'Which path makes this reachable?' },
{ key: 'ui.search.question.graph.blast_radius', fallback: 'What is the blast radius of this node?' },
{ key: 'ui.search.question.graph.next_hop', fallback: 'What should I inspect next on this path?' },
], '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'] },
{ key: 'ui.search.question.graph.clarify_node', fallback: 'Which node, package, or edge should I narrow this to?' },
{ key: 'ui.search.question.graph.clarify_need', fallback: 'Do you want reachability, impact, or next-step guidance?' },
], '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'] },
{ key: 'ui.search.question.ops_memory.pattern', fallback: 'Have we seen this pattern before?' },
{ key: 'ui.search.question.ops_memory.runbook', fallback: 'What runbook usually fixes this fastest?' },
{ key: 'ui.search.question.ops_memory.repeat', fallback: 'What repeated failures are related to this?' },
], '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'] },
{ key: 'ui.search.question.ops_memory.clarify_job', fallback: 'Which job, incident, or recurring failure should I narrow this to?' },
{ key: 'ui.search.question.ops_memory.clarify_need', fallback: 'Do you want precedent, likely cause, or recommended recovery?' },
], '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'] },
{ key: 'ui.search.question.timeline.before_incident', fallback: 'What changed before this incident?' },
{ key: 'ui.search.question.timeline.introduced_risk', fallback: 'Which release introduced this risk?' },
{ key: 'ui.search.question.timeline.next_event', fallback: 'What else happened around this event?' },
], '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'] },
{ key: 'ui.search.question.timeline.clarify_window', fallback: 'Which deployment, incident, or time window should I narrow this to?' },
{ key: 'ui.search.question.timeline.clarify_need', fallback: 'Do you want causes, impacts, or follow-up events?' },
], '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'] },
{ key: 'ui.search.question.releases.blocked', fallback: 'What blocked this promotion?' },
{ key: 'ui.search.question.releases.approvals', fallback: 'Which approvals are missing?' },
{ key: 'ui.search.question.releases.next_step', fallback: 'What is the safest next step to ship?' },
], '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'] },
{ key: 'ui.search.question.releases.clarify_target', fallback: 'Which environment or release should I narrow this to?' },
{ key: 'ui.search.question.releases.clarify_need', fallback: 'Do you want blockers, approvals, or policy impact?' },
], 'clarify'),
};
@@ -356,3 +351,4 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [
selfServe: RELEASES_SELF_SERVE,
},
] as const;

View File

@@ -1,110 +0,0 @@
import { Injectable, computed, signal } from '@angular/core';
export type SearchExperienceMode = 'find' | 'explain' | 'act';
export interface SearchExperienceModeDefinition {
id: SearchExperienceMode;
labelKey: string;
labelFallback: string;
descriptionKey: string;
descriptionFallback: string;
assistantDirective: string;
chatStarter: string;
}
export const SEARCH_EXPERIENCE_MODE_DEFINITIONS: readonly SearchExperienceModeDefinition[] = [
{
id: 'find',
labelKey: 'ui.search.mode.find',
labelFallback: 'Find',
descriptionKey: 'ui.search.mode.find.description',
descriptionFallback: 'Locate the strongest evidence, entities, and related records first.',
assistantDirective: 'Find the strongest evidence, related entities, and the fastest next pivot.',
chatStarter: 'Find the most relevant evidence and related entities for this page.',
},
{
id: 'explain',
labelKey: 'ui.search.mode.explain',
labelFallback: 'Explain',
descriptionKey: 'ui.search.mode.explain.description',
descriptionFallback: 'Understand why it matters with policy, VEX, and evidence context.',
assistantDirective: 'Explain what this means, why it matters, and cite the strongest evidence.',
chatStarter: 'Explain the evidence chain and policy impact behind the top issue.',
},
{
id: 'act',
labelKey: 'ui.search.mode.act',
labelFallback: 'Act',
descriptionKey: 'ui.search.mode.act.description',
descriptionFallback: 'Decide the safest next operator action and required follow-through.',
assistantDirective: 'Recommend the safest next operator action, blockers, and prerequisites.',
chatStarter: 'Recommend the safest next operator action and what I must verify first.',
},
] as const;
@Injectable({ providedIn: 'root' })
export class SearchExperienceModeService {
readonly definitions = SEARCH_EXPERIENCE_MODE_DEFINITIONS;
private readonly storageKey = 'stella-search-experience-mode';
private readonly selectedMode = signal<SearchExperienceMode>(this.resolveInitialMode());
readonly mode = this.selectedMode.asReadonly();
readonly definition = computed<SearchExperienceModeDefinition>(() =>
this.findDefinition(this.selectedMode()),
);
setMode(mode: SearchExperienceMode): void {
if (!this.isSupportedMode(mode) || this.selectedMode() === mode) {
return;
}
this.selectedMode.set(mode);
this.persistMode(mode);
}
currentMode(): SearchExperienceMode {
return this.selectedMode();
}
getDefinition(mode: SearchExperienceMode): SearchExperienceModeDefinition {
return this.findDefinition(mode);
}
private findDefinition(mode: SearchExperienceMode): SearchExperienceModeDefinition {
return this.definitions.find((definition) => definition.id === mode) ?? this.definitions[0];
}
private resolveInitialMode(): SearchExperienceMode {
if (typeof window === 'undefined') {
return 'find';
}
try {
const stored = window.localStorage.getItem(this.storageKey);
if (stored && this.isSupportedMode(stored)) {
return stored;
}
} catch {
return 'find';
}
return 'find';
}
private persistMode(mode: SearchExperienceMode): void {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(this.storageKey, mode);
} catch {
// Ignore localStorage failures and keep the in-memory selection.
}
}
private isSupportedMode(value: string): value is SearchExperienceMode {
return this.definitions.some((definition) => definition.id === value);
}
}

View File

@@ -72,6 +72,10 @@ type SearchAnswerView = {
questions: SearchQuestionView[];
nextSearches: SearchSuggestionView[];
};
type SuccessfulSearchHistoryEntry = {
query: string;
resultCount: number;
};
@Component({
selector: 'app-global-search',
@@ -117,14 +121,7 @@ type SearchAnswerView = {
@if (showResults()) {
<div class="search__results" id="search-results">
@if (showDegradedModeBanner()) {
<div class="search__degraded-banner" role="status" aria-live="polite">
<span class="search__degraded-title">{{ degradedModeLabel() }}</span>
{{ degradedModeMessage() }}
</div>
}
@if (query().trim().length >= 1 && searchResponse()?.suggestions?.length) {
@if (showInlineCorrection()) {
<div class="did-you-mean did-you-mean--inline">
<span class="did-you-mean__label">{{ t('ui.search.did_you_mean_label', 'Did you mean:') }}</span>
@for (suggestion of searchResponse()!.suggestions!; track suggestion.text) {
@@ -139,6 +136,13 @@ type SearchAnswerView = {
</div>
}
@if (showDegradedModeBanner()) {
<div class="search__degraded-banner" role="status" aria-live="polite">
<span class="search__degraded-title">{{ degradedModeLabel() }}</span>
{{ degradedModeMessage() }}
</div>
}
@if (searchAnswer(); as answer) {
<section
class="search__answer"
@@ -192,7 +196,7 @@ type SearchAnswerView = {
@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__group-label">{{ t('ui.search.answer.next_searches', 'Related searches') }}</div>
<div class="search__answer-next-actions">
@for (suggestion of answer.nextSearches; track suggestion.query) {
<button
@@ -218,7 +222,7 @@ type SearchAnswerView = {
@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 here') }}</div>
<div class="search__group-label">{{ t('ui.search.results.primary', 'Best match on this page') }}</div>
}
<div class="search__cards" role="list" [attr.aria-live]="'polite'">
@for (card of cards(); track card.entityKey; let i = $index) {
@@ -920,7 +924,8 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
private readonly i18n = inject(I18nService);
private readonly destroy$ = new Subject<void>();
private readonly searchTerms$ = new Subject<string>();
private readonly recentSearchStorageKey = 'stella-successful-searches-v2';
private readonly recentSearchStorageKey = 'stella-successful-searches-v3';
private readonly legacySuccessfulSearchStorageKey = 'stella-successful-searches-v2';
private readonly legacyRecentSearchStorageKey = 'stella-recent-searches';
private wasDegradedMode = false;
private escapeCount = 0;
@@ -949,6 +954,10 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|| this.isLoading()
|| this.query().trim().length >= 1,
);
readonly showInlineCorrection = computed(() =>
this.query().trim().length >= 1 &&
(this.searchResponse()?.suggestions?.length ?? 0) > 0,
);
readonly diagnosticsMode = computed(() => this.searchResponse()?.diagnostics?.mode ?? 'unknown');
readonly isDegradedMode = computed(() => {
const mode = this.diagnosticsMode();
@@ -1092,7 +1101,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
});
readonly commonQuestions = computed<SearchQuestionView[]>(() => {
return [...this.ambientContext.getCommonQuestions('find')]
return [...this.ambientContext.getCommonQuestions()]
.sort((left, right) => this.scoreQuestion(right) - this.scoreQuestion(left))
.filter((question) => this.isSuggestionQueryViable(this.i18n.tryT(question.key) ?? question.fallback))
.map((question) => ({
@@ -1102,7 +1111,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
});
readonly clarifyingQuestions = computed<SearchQuestionView[]>(() => {
return [...this.ambientContext.getClarifyingQuestions('find')]
return [...this.ambientContext.getClarifyingQuestions()]
.sort((left, right) => this.scoreQuestion(right) - this.scoreQuestion(left))
.map((question) => ({
query: this.i18n.tryT(question.key) ?? question.fallback,
@@ -1143,8 +1152,8 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
title: citation.title,
})),
questionLabel: backendAnswer.status === 'clarify'
? this.t('ui.search.answer.questions.clarify', 'Clarify with one of these')
: this.t('ui.search.answer.questions.follow_up', 'Ask next'),
? this.t('ui.search.answer.questions.clarify', 'Try one of these')
: this.t('ui.search.answer.questions.follow_up', 'Related questions'),
questions: this.buildBackendAnswerQuestions(backendAnswer),
nextSearches,
};
@@ -1160,7 +1169,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
summary: this.buildGroundedAnswerSummary(response),
evidence: this.buildGroundedEvidenceLabel(response),
citations: this.buildAnswerCitations(response),
questionLabel: this.t('ui.search.answer.questions.follow_up', 'Ask next'),
questionLabel: this.t('ui.search.answer.questions.follow_up', 'Related questions'),
questions: this.commonQuestions().slice(0, 3),
nextSearches,
};
@@ -1182,7 +1191,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
'No direct grounded answer was found from the current page context.',
),
citations: [],
questionLabel: this.t('ui.search.answer.questions.clarify', 'Clarify with one of these'),
questionLabel: this.t('ui.search.answer.questions.clarify', 'Try one of these'),
questions: clarifyingQuestions,
nextSearches,
};
@@ -1202,7 +1211,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
'Try a clearer target or open chat to deepen the search with page context.',
),
citations: [],
questionLabel: this.t('ui.search.answer.questions.retry', 'Try one of these questions'),
questionLabel: this.t('ui.search.answer.questions.retry', 'Try one of these'),
questions: this.commonQuestions().slice(0, 2),
nextSearches,
};
@@ -1260,12 +1269,12 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
}));
});
readonly overflowSectionTitle = computed(() =>
this.t('ui.search.results.overflow', 'Also relevant elsewhere'));
this.t('ui.search.results.overflow', 'Also worth checking'));
readonly overflowSectionReason = computed(() =>
this.searchResponse()?.overflow?.reason
?? this.t(
'ui.search.results.overflow.reason',
'These results are outside the current page weighting but remain strongly relevant.',
'These results are outside this page but still look strongly relevant.',
));
ngOnInit(): void {
@@ -1338,7 +1347,13 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
this.expandedCardKey.set(null);
this.isLoading.set(false);
if (this.hasSearchEvidence(response)) {
this.saveRecentSearch(response.query);
const overflowCount = 'overflow' in response
? (response.overflow?.cards.length ?? 0)
: 0;
this.saveRecentSearch(
response.query,
Math.max(1, response.cards.length + overflowCount),
);
}
// Sprint 106 / G6: Emit search analytics events
@@ -1734,11 +1749,8 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
try {
const stored = localStorage.getItem(this.recentSearchStorageKey);
if (stored) {
const parsed = JSON.parse(stored);
this.recentSearches.set(
Array.isArray(parsed)
? parsed.filter((item) => typeof item === 'string')
: [],
this.parseStoredRecentSearches(JSON.parse(stored)).map((entry) => entry.query),
);
} else {
this.recentSearches.set([]);
@@ -1748,11 +1760,16 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
}
}
private saveRecentSearch(query: string): void {
private saveRecentSearch(query: string, resultCount = 1): void {
const normalized = query.trim();
if (!normalized) return;
const next = [normalized, ...this.recentSearches().filter((item) => item !== normalized)].slice(0, 10);
const next = [
{ query: normalized, resultCount: Math.max(1, resultCount) },
...this.recentSearches()
.filter((item) => item !== normalized)
.map((item) => ({ query: item, resultCount: 1 })),
].slice(0, 10);
this.persistRecentSearches(next);
}
@@ -1764,9 +1781,19 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
const successfulEntries = entries.filter((entry) => (entry.resultCount ?? 0) > 0);
if (successfulEntries.length === 0) return;
const serverQueries = successfulEntries.map((e) => e.query);
const localQueries = this.recentSearches();
const merged = [...new Set([...localQueries, ...serverQueries])].slice(0, 10);
const serverEntries = successfulEntries.map((entry) => ({
query: entry.query,
resultCount: Math.max(1, entry.resultCount ?? 1),
}));
const localEntries = this.recentSearches().map((query) => ({
query,
resultCount: 1,
}));
const merged = [...serverEntries, ...localEntries]
.filter((entry, index, list) =>
list.findIndex((candidate) => candidate.query === entry.query) === index,
)
.slice(0, 10);
this.persistRecentSearches(merged);
});
}
@@ -1959,11 +1986,11 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
const normalized = query.trim().toLowerCase();
const viability = this.suggestionViability();
if (!normalized || !viability) {
return true;
return false;
}
const match = viability.suggestions.find((suggestion) => suggestion.query.trim().toLowerCase() === normalized);
return match ? match.viable : true;
return match ? match.viable : false;
}
private refreshSuggestionViability(): void {
@@ -1973,7 +2000,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
const queries = Array.from(new Set([
...this.ambientContext.getSearchSuggestions().map((suggestion) => this.i18n.tryT(suggestion.key) ?? suggestion.fallback),
...this.ambientContext.getCommonQuestions('find').map((question) => this.i18n.tryT(question.key) ?? question.fallback),
...this.ambientContext.getCommonQuestions().map((question) => this.i18n.tryT(question.key) ?? question.fallback),
].map((query) => query.trim()).filter((query) => query.length > 0))).slice(0, 12);
if (queries.length === 0) {
@@ -2168,8 +2195,8 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|| (response.synthesis?.sourceCount ?? 0) > 0;
}
private persistRecentSearches(entries: string[]): void {
this.recentSearches.set(entries);
private persistRecentSearches(entries: readonly SuccessfulSearchHistoryEntry[]): void {
this.recentSearches.set(entries.map((entry) => entry.query));
try {
localStorage.setItem(this.recentSearchStorageKey, JSON.stringify(entries));
} catch {
@@ -2177,6 +2204,22 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
}
}
private parseStoredRecentSearches(value: unknown): SuccessfulSearchHistoryEntry[] {
if (!Array.isArray(value)) {
return [];
}
return value
.filter((entry): entry is SuccessfulSearchHistoryEntry =>
!!entry &&
typeof entry === 'object' &&
typeof (entry as SuccessfulSearchHistoryEntry).query === 'string' &&
typeof (entry as SuccessfulSearchHistoryEntry).resultCount === 'number' &&
(entry as SuccessfulSearchHistoryEntry).resultCount > 0,
)
.slice(0, 10);
}
private normalizeActionRoute(route: string): string {
return normalizeSearchActionRoute(route);
}
@@ -2210,6 +2253,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
this.recentSearches.set([]);
try {
localStorage.removeItem(this.recentSearchStorageKey);
localStorage.removeItem(this.legacySuccessfulSearchStorageKey);
localStorage.removeItem(this.legacyRecentSearchStorageKey);
} catch {
// Ignore localStorage failures.

View File

@@ -36,7 +36,7 @@ describe('AmbientContextService', () => {
it('returns page-owned common questions for the active route', () => {
const service = TestBed.inject(AmbientContextService);
const questions = service.getCommonQuestions('explain').map((item) => item.fallback);
const questions = service.getCommonQuestions().map((item) => item.fallback);
expect(questions).toContain('Why is this exploitable in my environment?');
expect(questions).toContain('What evidence blocks this release?');
@@ -80,7 +80,6 @@ describe('AmbientContextService', () => {
reasonKey: 'ui.search.suggestion.reason.last_action',
reasonFallback: 'Based on your last actions on this page.',
kind: 'recent',
preferredModes: ['find', 'explain', 'act'],
});
expect(suggestions.map((item) => item.key)).toContain('ui.search.suggestion.contextual.chat.policy_vex');
});
@@ -107,7 +106,7 @@ describe('AmbientContextService', () => {
expect(followUps).toContain('follow up: CVE-2024-21626');
});
it('builds a recent-action question for the active mode', () => {
it('builds a recent-action question for the active route context', () => {
const service = TestBed.inject(AmbientContextService);
service.recordAction({
action: 'search_result_open',
@@ -115,12 +114,11 @@ describe('AmbientContextService', () => {
domain: 'findings',
});
const questions = service.getCommonQuestions('act');
const questions = service.getCommonQuestions();
expect(questions[0]).toEqual(jasmine.objectContaining({
key: 'ui.search.question.recent_action.default',
fallback: 'What should I do next for CVE-2024-21626?',
fallback: 'Why does CVE-2024-21626 matter on this page?',
kind: 'recent',
preferredModes: ['act'],
}));
});
@@ -200,7 +198,7 @@ describe('AmbientContextService', () => {
router.url = '/ops/policy';
events.next(new NavigationEnd(1, '/ops/policy', '/ops/policy'));
const questions = service.getClarifyingQuestions('act').map((item) => item.fallback);
const questions = service.getClarifyingQuestions().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

@@ -1,49 +0,0 @@
import { TestBed } from '@angular/core/testing';
import {
SearchExperienceModeService,
SEARCH_EXPERIENCE_MODE_DEFINITIONS,
} from '../../app/core/services/search-experience-mode.service';
describe('SearchExperienceModeService', () => {
let service: SearchExperienceModeService;
beforeEach(() => {
localStorage.clear();
TestBed.resetTestingModule();
TestBed.configureTestingModule({});
service = TestBed.inject(SearchExperienceModeService);
});
it('defaults to find mode when nothing is stored', () => {
expect(service.currentMode()).toBe('find');
expect(service.definition().id).toBe('find');
});
it('persists the selected mode in localStorage', () => {
service.setMode('act');
expect(service.currentMode()).toBe('act');
expect(localStorage.getItem('stella-search-experience-mode')).toBe('act');
});
it('hydrates from a valid stored mode', () => {
localStorage.setItem('stella-search-experience-mode', 'explain');
TestBed.resetTestingModule();
TestBed.configureTestingModule({});
const hydrated = TestBed.inject(SearchExperienceModeService);
expect(hydrated.currentMode()).toBe('explain');
expect(hydrated.definition().labelFallback).toBe('Explain');
});
it('ignores unsupported stored values', () => {
localStorage.setItem('stella-search-experience-mode', 'invalid-mode');
TestBed.resetTestingModule();
TestBed.configureTestingModule({});
const hydrated = TestBed.inject(SearchExperienceModeService);
expect(hydrated.currentMode()).toBe('find');
expect(hydrated.getDefinition('find')).toEqual(SEARCH_EXPERIENCE_MODE_DEFINITIONS[0]);
});
});

View File

@@ -52,7 +52,38 @@ describe('GlobalSearchComponent', () => {
mode: 'fts-only',
},
}));
searchClient.evaluateSuggestions.and.returnValue(of(null));
searchClient.evaluateSuggestions.and.returnValue(of({
suggestions: [
{
query: 'How do I deploy?',
viable: true,
status: 'grounded',
code: 'retrieved_scope_weighted_evidence',
cardCount: 1,
leadingDomain: 'findings',
reason: 'Evidence is available for this suggestion.',
},
{
query: 'What evidence blocks this release?',
viable: true,
status: 'grounded',
code: 'retrieved_scope_weighted_evidence',
cardCount: 1,
leadingDomain: 'findings',
reason: 'Evidence is available for this question.',
},
{
query: 'Why is this exploitable in my environment?',
viable: true,
status: 'grounded',
code: 'retrieved_scope_weighted_evidence',
cardCount: 1,
leadingDomain: 'findings',
reason: 'Evidence is available for this question.',
},
],
coverage: null,
}));
searchClient.getHistory.and.returnValue(of([]));
ambientContext = jasmine.createSpyObj('AmbientContextService', [
@@ -110,19 +141,16 @@ describe('GlobalSearchComponent', () => {
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([
@@ -130,13 +158,11 @@ describe('GlobalSearchComponent', () => {
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?',
fallback: 'What part of this finding matters most right now?',
kind: 'clarify',
preferredModes: ['find', 'act'],
},
]);
ambientContext.buildAmbientContext.and.returnValue({
@@ -186,13 +212,12 @@ describe('GlobalSearchComponent', () => {
it('renders the global search input', () => {
const input = fixture.nativeElement.querySelector('input[aria-label="Global search"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
expect(input?.placeholder).toContain('Try:');
expect(input?.placeholder).toContain('Search everything');
});
it('interpolates fallback placeholder params when translation key is missing', () => {
const placeholder = component.inputPlaceholder();
expect(placeholder).toBe('Try: How do I deploy?');
expect(placeholder).not.toContain('{suggestion}');
expect(placeholder).toBe('Search everything...');
});
it('collapses the empty state into current-page starters without product teaching panels', () => {
@@ -529,7 +554,7 @@ describe('GlobalSearchComponent', () => {
const overflowSection = fixture.nativeElement.querySelector('[data-overflow-results]') as HTMLElement | null;
expect(overflowSection).not.toBeNull();
expect(fixture.nativeElement.querySelector('.search__scope-hint')).toBeNull();
expect(overflowSection?.textContent).toContain('Also relevant elsewhere');
expect(overflowSection?.textContent).toContain('Also worth checking');
expect(overflowSection?.textContent).toContain('Related policy evidence is relevant but secondary to the Doctor page context.');
});
@@ -583,7 +608,7 @@ describe('GlobalSearchComponent', () => {
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?');
expect(answerQuestions).toContain('What part of this finding matters most right now?');
});
it('does not hard-filter search requests to the current route scope', async () => {
@@ -653,6 +678,7 @@ describe('GlobalSearchComponent', () => {
});
it('ignores legacy mixed-result local history keys and persists the successful-only key', () => {
localStorage.setItem('stella-successful-searches-v2', JSON.stringify(['old zero result', 'old success']));
localStorage.setItem('stella-recent-searches', JSON.stringify(['old zero result', 'old success']));
searchClient.search.and.returnValue(of({
query: 'critical findings',
@@ -677,7 +703,12 @@ describe('GlobalSearchComponent', () => {
component.onQueryChange('critical findings');
return waitForDebounce().then(() => {
expect(JSON.parse(localStorage.getItem('stella-successful-searches-v2') ?? '[]')).toEqual(['critical findings']);
expect(JSON.parse(localStorage.getItem('stella-successful-searches-v3') ?? '[]')).toEqual([
{
query: 'critical findings',
resultCount: 1,
},
]);
expect(localStorage.getItem('stella-recent-searches')).toBe(JSON.stringify(['old zero result', 'old success']));
});
});

View File

@@ -149,7 +149,10 @@ test.describe('Unified Search - Live contextual suggestions', () => {
await searchInput.focus();
await waitForResults(page);
const suggestionTexts = (await page.locator('.search__suggestions .search__chip').allTextContents())
const suggestionChips = page.locator('.search__suggestions .search__chip');
await expect(suggestionChips.first()).toBeVisible({ timeout: 10_000 });
const suggestionTexts = (await suggestionChips.allTextContents())
.map((text) => text.trim())
.filter((text) => text.length > 0);

View File

@@ -310,7 +310,7 @@ test.describe('Unified Search - Experience Quality UX', () => {
/current-page findings matched first/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(/also worth checking/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

@@ -114,6 +114,40 @@ export async function setupBasicMocks(page: Page) {
}
return route.fulfill({ status: 400, body: 'blocked' });
});
await page.route('**/api/v1/search/suggestions/evaluate', async (route) => {
const body = (route.request().postDataJSON() as { queries?: string[] } | null) ?? {};
const queries = body.queries ?? [];
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
suggestions: queries.map((query) => ({
query,
viable: true,
status: 'grounded',
code: 'retrieved_scope_weighted_evidence',
cardCount: 1,
leadingDomain: 'findings',
reason: 'Evidence is available for this suggestion.',
})),
coverage: {
currentScopeDomain: 'findings',
currentScopeWeighted: true,
domains: [
{
domain: 'findings',
candidateCount: Math.max(1, queries.length),
visibleCardCount: Math.max(1, queries.length),
topScore: 0.9,
isCurrentScope: true,
hasVisibleResults: true,
},
],
},
}),
});
});
}
export async function setupAuthenticatedSession(page: Page) {