diff --git a/docs/implplan/SPRINT_20260307_032_FE_search_primary_surface_cleanup.md b/docs/implplan/SPRINT_20260307_032_FE_search_primary_surface_cleanup.md index 0599955c0..526bc49ed 100644 --- a/docs/implplan/SPRINT_20260307_032_FE_search_primary_surface_cleanup.md +++ b/docs/implplan/SPRINT_20260307_032_FE_search_primary_surface_cleanup.md @@ -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. diff --git a/docs/modules/ui/search-chip-context-contract.md b/docs/modules/ui/search-chip-context-contract.md index b21a7ebbe..afeadc4aa 100644 --- a/docs/modules/ui/search-chip-context-contract.md +++ b/docs/modules/ui/search-chip-context-contract.md @@ -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. diff --git a/docs/modules/ui/search-self-serve-contract.md b/docs/modules/ui/search-self-serve-contract.md index bef6b3bcb..df40fac26 100644 --- a/docs/modules/ui/search-self-serve-contract.md +++ b/docs/modules/ui/search-self-serve-contract.md @@ -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: diff --git a/docs/modules/ui/search-zero-learning-primary-entry.md b/docs/modules/ui/search-zero-learning-primary-entry.md index d8d082448..87ee9b689 100644 --- a/docs/modules/ui/search-zero-learning-primary-entry.md +++ b/docs/modules/ui/search-zero-learning-primary-entry.md @@ -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 diff --git a/src/Web/StellaOps.Web/src/app/core/services/ambient-context.service.ts b/src/Web/StellaOps.Web/src/app/core/services/ambient-context.service.ts index de955d88f..9107eaee5 100644 --- a/src/Web/StellaOps.Web/src/app/core/services/ambient-context.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/services/ambient-context.service.ts @@ -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], }; } diff --git a/src/Web/StellaOps.Web/src/app/core/services/search-context.registry.ts b/src/Web/StellaOps.Web/src/app/core/services/search-context.registry.ts index ac62ab7da..9b8c5d20a 100644 --- a/src/Web/StellaOps.Web/src/app/core/services/search-context.registry.ts +++ b/src/Web/StellaOps.Web/src/app/core/services/search-context.registry.ts @@ -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; + diff --git a/src/Web/StellaOps.Web/src/app/core/services/search-experience-mode.service.ts b/src/Web/StellaOps.Web/src/app/core/services/search-experience-mode.service.ts deleted file mode 100644 index d8d6e7da4..000000000 --- a/src/Web/StellaOps.Web/src/app/core/services/search-experience-mode.service.ts +++ /dev/null @@ -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(this.resolveInitialMode()); - - readonly mode = this.selectedMode.asReadonly(); - readonly definition = computed(() => - 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); - } -} diff --git a/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts b/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts index bb87d8af5..cda59b4f3 100644 --- a/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts @@ -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()) {
- @if (showDegradedModeBanner()) { -
- {{ degradedModeLabel() }} - {{ degradedModeMessage() }} -
- } - - @if (query().trim().length >= 1 && searchResponse()?.suggestions?.length) { + @if (showInlineCorrection()) {
{{ t('ui.search.did_you_mean_label', 'Did you mean:') }} @for (suggestion of searchResponse()!.suggestions!; track suggestion.text) { @@ -139,6 +136,13 @@ type SearchAnswerView = {
} + @if (showDegradedModeBanner()) { +
+ {{ degradedModeLabel() }} + {{ degradedModeMessage() }} +
+ } + @if (searchAnswer(); as answer) {
0) {
-
{{ t('ui.search.answer.next_searches', 'Search next') }}
+
{{ t('ui.search.answer.next_searches', 'Related searches') }}
@for (suggestion of answer.nextSearches; track suggestion.query) {