Simplify the primary search surface
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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?');
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user