From 9b86ad825ae6f09b34b7172545c2c665fc1dcfb9 Mon Sep 17 00:00:00 2001 From: master <> Date: Fri, 6 Mar 2026 19:13:26 +0200 Subject: [PATCH] Improve search and advisory UX flows --- ...60306_002_FE_search_advisory_quality_ux.md | 136 ++++ docs/modules/advisory-ai/knowledge-search.md | 4 + docs/modules/ui/TASKS.md | 9 + docs/modules/ui/architecture.md | 6 + .../ui/search-chip-context-contract.md | 30 +- .../core/services/ambient-context.service.ts | 221 +++++- .../services/search-chat-context.service.ts | 5 +- .../core/services/search-context.registry.ts | 136 +++- .../search-experience-mode.service.ts | 110 +++ .../chat/chat-message.component.ts | 441 +++++++++++- .../advisory-ai/chat/chat.component.ts | 116 ++- .../security-triage-chat-host.component.ts | 13 +- .../global-search/global-search.component.ts | 680 +++++++++++++++++- .../chat-message.component.spec.ts | 46 +- .../advisory_ai_chat/chat.component.spec.ts | 117 +++ .../context/ambient-context.service.spec.ts | 33 + .../search-experience-mode.service.spec.ts | 49 ++ .../global-search.component.spec.ts | 159 +++- ...-search-contextual-suggestions.e2e.spec.ts | 22 +- ...fied-search-experience-quality.e2e.spec.ts | 320 +++++++++ 20 files changed, 2530 insertions(+), 123 deletions(-) create mode 100644 docs/implplan/SPRINT_20260306_002_FE_search_advisory_quality_ux.md create mode 100644 src/Web/StellaOps.Web/src/app/core/services/search-experience-mode.service.ts create mode 100644 src/Web/StellaOps.Web/src/tests/advisory_ai_chat/chat.component.spec.ts create mode 100644 src/Web/StellaOps.Web/src/tests/context/search-experience-mode.service.spec.ts create mode 100644 src/Web/StellaOps.Web/tests/e2e/unified-search-experience-quality.e2e.spec.ts diff --git a/docs/implplan/SPRINT_20260306_002_FE_search_advisory_quality_ux.md b/docs/implplan/SPRINT_20260306_002_FE_search_advisory_quality_ux.md new file mode 100644 index 000000000..395b27d63 --- /dev/null +++ b/docs/implplan/SPRINT_20260306_002_FE_search_advisory_quality_ux.md @@ -0,0 +1,136 @@ +# Sprint 20260306-002 - Search and Advisory Quality UX + +## Topic & Scope +- Upgrade global search and AdvisoryAI UX from "smart suggestions" to "explainable operator guidance" with visible context, rationale, and next-step framing. +- Make contextual search state legible: current page, active domain, and latest meaningful action should be visible in the search surface. +- Improve suggestion quality UX with richer chips that explain why they are being suggested and what type of action they represent. +- Working directory: `src/Web/StellaOps.Web`. +- Expected evidence: Angular unit tests, Playwright behavioral tests, updated UI docs, and sprint execution log entries. + +## Dependencies & Concurrency +- Depends on `docs/implplan/SPRINT_20260306_001_Web_contextual_search_suggestions.md` for ambient context capture and chip registry groundwork. +- Upstream implementation points: + - `src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts` + - `src/Web/StellaOps.Web/src/app/core/services/ambient-context.service.ts` + - `src/Web/StellaOps.Web/src/app/core/services/search-context.registry.ts` + - `src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat-message.component.ts` +- Safe parallelism: + - UI presentation work can run in parallel with docs updates once chip-view contract is frozen. + - Search mode / zero-result rescue tasks should start after context-rail patterns are stable. + +## Documentation Prerequisites +- `docs/modules/ui/architecture.md` +- `docs/modules/ui/search-chip-context-contract.md` +- `docs/modules/ui/implementation_plan.md` +- `docs/modules/advisory-ai/knowledge-search.md` + +## Delivery Tracker + +### FE-UX-001 - Search context rail and explainable chips +Status: DONE +Dependency: none +Owners: Developer (FE), UX +Task description: +- Add a visible context rail to the global search panel that shows: + - current page context + - active domain focus + - most recent meaningful action on the current page +- Upgrade chip rendering so suggestion chips can expose rationale text and suggestion intent/category instead of showing only raw query text. +- Keep the layout compact enough for header search use while remaining legible on mobile. + +Completion criteria: +- [x] Empty-state search renders a context rail with page/domain/last-action information when available. +- [x] Suggestion chips can render rationale text from the shared chip contract. +- [x] Unit and Playwright coverage validate the new UI behavior. + +### FE-UX-002 - Search modes across search and AdvisoryAI +Status: DONE +Dependency: FE-UX-001 +Owners: Developer (FE), UX +Task description: +- Introduce explicit operator modes for the search/advisory experience: + - `Find` + - `Explain` + - `Act` +- Use the selected mode to influence chip ordering, Ask-AI prompts, and zero-result rescue actions. + +Completion criteria: +- [x] Mode switch is visible and keyboard accessible. +- [x] Search and Ask-AI handoff respect the selected mode. +- [x] Docs describe mode behavior and fallback semantics. + +### FE-UX-003 - Zero-result rescue and reformulation UX +Status: DONE +Dependency: FE-UX-001 +Owners: Developer (FE), UX +Task description: +- Improve no-result and sparse-result states with guided recovery actions: + - broaden scope + - search related domains + - ask AdvisoryAI to reformulate + - retry with visible entities / current page focus + +Completion criteria: +- [x] Zero-result states provide at least three recovery actions. +- [x] Recovery actions record contextual intent for follow-up search quality analysis. +- [x] Playwright tests cover rescue behavior end-to-end. + +### FE-UX-004 - AdvisoryAI evidence-first next-step cards +Status: DONE +Dependency: FE-UX-002 +Owners: Developer (FE), Developer (AdvisoryAI), UX +Task description: +- Upgrade AdvisoryAI responses to render structured next-step cards for common operator flows: + - search deeper + - inspect evidence chain + - compare policy impact + - open timeline / graph / VEX +- Keep all actions explicit and provenance-linked. + +Completion criteria: +- [x] AdvisoryAI can render at least two structured next-step card types. +- [x] Cards preserve evidence-first behavior and do not hide provenance. +- [x] Search return flows remain deterministic. + +### FE-UX-005 - Docs sync and rollout notes +Status: DONE +Dependency: FE-UX-001 +Owners: Documentation author, Project Manager +Task description: +- Update UI and AdvisoryAI docs with the new UX contract and rollout guidance. +- Record rationale for the chosen quality UX direction and phased delivery order. + +Completion criteria: +- [x] Architecture docs reflect context rail + explainable chip behavior. +- [x] Sprint Decisions & Risks capture rollout tradeoffs. +- [x] Execution log links implementation and verification evidence. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-06 | Sprint created for quality UX improvements on top of contextual search/advisory groundwork. | Project Manager | +| 2026-03-06 | FE-UX-001 started: implementing context rail and explainable suggestion chips in global search. | Developer (FE) | +| 2026-03-06 | FE-UX-001 completed: added search context rail, explainable suggestion cards, shared registry presentation/reason contract, targeted Angular tests (19/19), and Playwright contextual-suggestions verification (3/3). | Developer (FE) | +| 2026-03-06 | FE-UX-002 completed: added shared `Find`/`Explain`/`Act` mode state, keyboard-accessible mode switchers in search and AdvisoryAI, and mode-aware search-to-chat handoff prompts. | Developer (FE) | +| 2026-03-06 | FE-UX-003 completed: added zero-result rescue actions for scope broadening, related pivots, page-context retry, and AdvisoryAI reformulation; fixed search-panel focus containment so internal controls do not collapse the surface. | Developer (FE) | +| 2026-03-06 | FE-UX-004 completed: AdvisoryAI assistant messages now render evidence-first next-step cards (search, evidence, policy, context) and return structured search intent back into global search deterministically. | Developer (FE) | +| 2026-03-06 | FE-UX-005 completed: updated UI and AdvisoryAI docs, added targeted Angular coverage (39/39), and passed Playwright behavioral suites for contextual suggestions plus quality UX flows (7/7). | Developer (FE) | + +## Decisions & Risks +- Decision: the first quality UX slice will prioritize explainability over more hidden ranking logic. +- Decision: chip rationale should come from the shared registry/service contract, not component-local hardcoding. +- Decision: executable suggestion text and visible rationale must stay separate so clicking a chip always submits only the intended query string. +- Decision: search/advisory mode is a shared operator state (`Find`, `Explain`, `Act`) and not a local cosmetic toggle; prompts, chip ranking, and rescue actions all consume the same mode service. +- Decision: focus transitions inside the global-search surface must not collapse the panel; mode switches, scope toggles, and rescue buttons are part of the same command surface. +- Decision: AdvisoryAI next-step cards remain evidence-first by always exposing a direct evidence/open-context path in addition to search pivots. +- Risk: richer chip content may overcrowd the header search dropdown. +- Mitigation: use compact rail layout, limit visible chips, and keep rationale to a single short line. +- Risk: page/domain labels can drift from actual route behavior. +- Mitigation: derive labels from the same shared registry used for suggestion selection and context-rail presentation. +- Risk: explicit mode state can drift between search and chat if separate stores are introduced. +- Mitigation: keep a single root `SearchExperienceModeService` and verify handoff behavior in unit and Playwright coverage. + +## Next Checkpoints +- 2026-03-07: FE-UX-001 through FE-UX-005 shipped and verified. +- 2026-03-09: Monitor search/advisory telemetry for rescue-action usage and mode adoption. +- 2026-03-11: Use field feedback to decide whether to add richer per-page card presets beyond search/policy/evidence/context. diff --git a/docs/modules/advisory-ai/knowledge-search.md b/docs/modules/advisory-ai/knowledge-search.md index 9a3b26458..1b43099ed 100644 --- a/docs/modules/advisory-ai/knowledge-search.md +++ b/docs/modules/advisory-ai/knowledge-search.md @@ -123,6 +123,7 @@ Implemented in `src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSea - Global search emits ambient context with each unified query: `currentRoute`, `visibleEntityKeys`, `recentSearches`, `sessionId`, and optional `lastAction` (`action`, `source`, `queryHint`, `domain`, `entityKey`, `route`, `occurredAt`). - Contract remains backward-compatible: if an API deployment does not yet consume `lastAction`, unknown ambient fields are ignored and base search behavior remains unchanged. - UI suggestion behavior now combines obvious route defaults with one strategic non-obvious suggestion and action-aware variants (for example, policy/VEX impact and incident timeline pivots). + - Search and AdvisoryAI also share a persisted operator mode (`Find`, `Explain`, `Act`); the UI uses the same mode to rank chips, compose Ask-AI prompts, and label assistant return flows, while backend query contracts remain backward-compatible. - Unified index lifecycle: - Manual rebuild endpoint: `POST /v1/search/index/rebuild`. - Optional background refresh loop is available via `KnowledgeSearchOptions` (`UnifiedAutoIndexEnabled`, `UnifiedAutoIndexOnStartup`, `UnifiedIndexRefreshIntervalSeconds`). @@ -170,6 +171,9 @@ Global search now consumes AKS and supports: - API: `Curl` (copy command). - Doctor: `Run` (navigate to doctor and copy run command). - `More` action for "show more like this" local query expansion. +- A shared mode switch (`Find`, `Explain`, `Act`) across search and AdvisoryAI with mode-aware chip ranking and handoff prompts. +- Zero-result rescue actions that keep the current query visible while broadening scope, trying a related pivot, retrying with page context, or opening AdvisoryAI reformulation. +- AdvisoryAI evidence-first next-step cards that can return search pivots (`chat_next_step_search`, `chat_next_step_policy`) back into global search or open cited evidence/context directly. - Search-quality metrics taxonomy is standardized on `query`, `click`, and `zero_result` event types (no legacy `search` event dependency in quality SQL). - Synthesis usage is tracked via dedicated `synthesis` analytics events, while quality aggregates continue to compute totals from `query` + `zero_result`. - Quality dashboard query dimensions are exposed as query hashes (not raw query text) for privacy-preserving analytics. diff --git a/docs/modules/ui/TASKS.md b/docs/modules/ui/TASKS.md index 15784df2c..98c03f660 100644 --- a/docs/modules/ui/TASKS.md +++ b/docs/modules/ui/TASKS.md @@ -3,6 +3,7 @@ ## Active Sprint Links - `docs/implplan/SPRINT_20260221_041_FE_prealpha_ia_ops_setup_rewire.md` - `docs/implplan/SPRINT_20260306_001_Web_contextual_search_suggestions.md` +- `docs/implplan/SPRINT_20260306_002_FE_search_advisory_quality_ux.md` ## Delivery Tasks - [DONE] 041-T1 Root IA/nav rewrite (Mission Control + Ops + Setup) @@ -21,5 +22,13 @@ - [DONE] WEB-CTX-003 FE -> AdvisoryAI ambient payload activation - [DOING] WEB-CTX-005 Context-aware suggestion UX updates - [DOING] WEB-CTX-007 Docs sync and rollout plan +- [DONE] WEB-CTX-005A Playwright exhaustive query matrix (>1000 query types) +- [DONE] FE-UX-001 Search context rail and explainable chips +- [DONE] FE-UX-002 Search/AdvisoryAI shared mode switch (`Find` / `Explain` / `Act`) +- [DONE] FE-UX-003 Zero-result rescue and reformulation UX +- [DONE] FE-UX-004 AdvisoryAI evidence-first next-step cards +- [DONE] FE-UX-005 Docs sync and rollout notes for search/advisory quality UX - [DONE] WEB-CTX-E2E Playwright coverage for contextual suggestions + ambient last-action payload +- [DONE] FE-UX-E2E Playwright coverage for mode switching, rescue flows, and AdvisoryAI next-step cards - [DONE] WEB-CTX-NONOBVIOUS Strategic non-obvious suggestion recipes (cross-domain + action-aware) +- [DOING] FE-QA-LOOP-001 Web-only Playwright full-iteration loop at stella-ops.local (fresh route/action evidence, triage, fix, retest) diff --git a/docs/modules/ui/architecture.md b/docs/modules/ui/architecture.md index e9e5aab79..9a1ce22b2 100644 --- a/docs/modules/ui/architecture.md +++ b/docs/modules/ui/architecture.md @@ -194,8 +194,14 @@ Each feature folder builds as a **standalone route** (lazy loaded). All HTTP sha * **Assistant -> search return**: assistant responses expose `Search for more` and `Search related` actions; these populate global search query/domain context and focus the search surface. * **Guided discovery empty state**: when global search is focused with an empty query, the panel renders an 8-domain guide (findings, VEX, policy, docs, API, health, operations, timeline), contextual suggestion chips, and quick actions (`Getting Started`, `Run Health Check`, `View Recent Scans`). * **Automatic page-open suggestions**: `AmbientContextService` tracks router navigation and updates global-search suggestion chips/placeholders automatically for every opened page without requiring manual refresh. +* **Context rail**: empty-state search also renders a compact rail showing the current page title/description, active domain scope, and last meaningful action on the same page scope when available. * **Last-action follow-up suggestions**: the same service keeps a per-route scoped last action (search result open/action, Ask AI handoff, chat return actions) with deterministic TTL bounds; empty-state chips prepend a contextual `follow up: ...` suggestion when available. * **Strategic non-obvious suggestions**: each page scope injects at least one cross-domain guidance query (for example, findings -> policy/VEX impact, policy -> impacted findings, doctor -> release blockers) and switches to action-aware variants after meaningful user actions. +* **Explainable chips**: suggestion queries remain short and executable, while a separate rationale line explains whether a chip comes from page defaults, recent actions, or strategic cross-domain guidance. +* **Shared operator modes**: global search and AdvisoryAI share one persisted mode state (`Find`, `Explain`, `Act`) via `SearchExperienceModeService`; the mode changes chip ordering, Ask-AI prompts, empty-state starters, and structured next-step behavior. +* **Zero-result rescue loop**: no-result states must expose recovery actions for broadening scope, trying a related pivot, retrying with page context, and opening AdvisoryAI reformulation with the active mode and query context preserved. +* **Search-surface focus rule**: focus movement into controls inside the global-search surface (mode buttons, scope toggle, rescue buttons, filters) must not collapse the panel; the surface behaves like one command workspace rather than a disposable tooltip. +* **AdvisoryAI next-step cards**: assistant responses with citations render structured cards for evidence inspection, context navigation, deeper search, and policy pivots; search-return actions must emit deterministic `chat_next_step_*` metadata back into global search. * **Ambient payload activation**: each global search request sends ambient context (`currentRoute`, `visibleEntityKeys`, `recentSearches`, `sessionId`, optional `lastAction`) so AdvisoryAI can apply contextual ranking/refinement. * **Chip contract governance**: page-owned chip arrays and route mappings are defined by `docs/modules/ui/search-chip-context-contract.md` and implemented in `search-context.registry.ts`. * **Fallback transparency**: when unified search drops to legacy fallback, global search displays an explicit degraded banner and emits enter/exit telemetry markers for operator visibility. diff --git a/docs/modules/ui/search-chip-context-contract.md b/docs/modules/ui/search-chip-context-contract.md index c8e9ae2d4..a2f582a62 100644 --- a/docs/modules/ui/search-chip-context-contract.md +++ b/docs/modules/ui/search-chip-context-contract.md @@ -11,14 +11,22 @@ - Context definitions must provide: - `id` - `routePrefixes` + - `presentation` (`titleKey/titleFallback`, `descriptionKey/descriptionFallback`) for the search context rail - optional `domain` - optional `searchSuggestions[]` - optional `chatSuggestions[]` - optional `chatRoutePattern` +- Search suggestion entries should provide: + - `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 ## Source of truth - Contract and registry: @@ -27,17 +35,35 @@ - `src/Web/StellaOps.Web/src/app/core/services/ambient-context.service.ts` ## Runtime behavior +- The empty-state search panel renders a context rail with: + - page title/description from `presentation` + - active domain token when available + - last-action token when recent scoped action history exists - Base chips come from the page context array. - A deterministic rotation (session + route scope + 5-minute bucket) varies chip order. - Last few actions for the current page scope are tracked (bounded history, TTL 15 minutes). - Up to 2 `follow up: ...` chips are generated from recent actions and prioritized above base chips. - One strategic chip is generated from dominant/recent action intent. +- 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 +- Search-surface control rule: + - buttons inside the search surface (mode switch, scope toggle, rescue cards, filters) 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 ## Page ownership workflow 1. Add/adjust a context in `SEARCH_CONTEXT_DEFINITIONS`. 2. Ensure page component exposes the same `searchContextId` (implements `SearchContextComponent`). -3. Add/adjust unit tests in `ambient-context.service.spec.ts`. -4. Add/adjust Playwright tests for route chips + action-driven chips. +3. Define or update `presentation` copy for the context rail. +4. Add or update `reasonFallback` text and any `preferredModes` metadata 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. ## Non-goals - No unbounded per-page suggestion memory. 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 9c5c93421..b7cdf9460 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 @@ -1,6 +1,7 @@ import { Injectable, inject, signal } from '@angular/core'; import { NavigationEnd, Router } from '@angular/router'; import { filter } from 'rxjs/operators'; +import { DOMAIN_LABELS } from '../api/unified-search.models'; import type { UnifiedSearchAmbientAction, UnifiedSearchAmbientContext, @@ -17,6 +18,21 @@ import { export type ContextSuggestion = SearchSuggestionChip; +export interface SearchContextPanelToken { + key: string; + labelKey: string; + labelFallback: string; + value: string; +} + +export interface SearchContextPanel { + titleKey: string; + titleFallback: string; + descriptionKey: string; + descriptionFallback: string; + tokens: readonly SearchContextPanelToken[]; +} + export interface AmbientActionInput { action: string; source?: string; @@ -62,6 +78,30 @@ export class AmbientContextService { return context?.domain ?? null; } + getSearchContextPanel(): SearchContextPanel | null { + const route = this.routeUrl(); + const scope = this.routeScope(route); + const context = this.findContext(route); + const recentActions = this.getActiveActions(scope); + const lastAction = recentActions[0] ?? null; + + if (!context && !lastAction) { + return null; + } + + const titleFallback = context?.presentation?.titleFallback ?? this.humanizeScope(scope) ?? 'Current context'; + const panel: SearchContextPanel = { + titleKey: context?.presentation?.titleKey ?? 'ui.search.context.default.title', + titleFallback, + descriptionKey: context?.presentation?.descriptionKey ?? 'ui.search.context.default.description', + descriptionFallback: context?.presentation?.descriptionFallback + ?? 'Suggestions adapt to this page and your recent actions.', + tokens: this.buildContextPanelTokens(context, titleFallback, lastAction), + }; + + return panel; + } + getSearchSuggestions(): readonly ContextSuggestion[] { const route = this.routeUrl(); const scope = this.resolveSearchSuggestionScope(route); @@ -178,6 +218,10 @@ export class AmbientContextService { suggestions.push({ key: 'ui.search.suggestion.last_action.follow_up', fallback: `follow up: ${hint}`, + 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) { @@ -202,45 +246,45 @@ export class AmbientContextService { const hint = latestAction ? this.buildActionHint(latestAction) : null; switch (scope) { case 'findings': - return { - key: 'ui.search.suggestion.contextual.findings.policy_vex', - fallback: hint + return this.createStrategicSuggestion( + 'ui.search.suggestion.contextual.findings.policy_vex', + hint ? `policy and VEX impact of ${hint}` : 'policy and VEX impact of critical findings', - }; + ); case 'policy': - return { - key: 'ui.search.suggestion.contextual.policy.findings_impact', - fallback: hint + return this.createStrategicSuggestion( + 'ui.search.suggestion.contextual.policy.findings_impact', + hint ? `findings impacted by policy ${hint}` : 'findings impacted by current policy rules', - }; + ); case 'doctor': - return { - key: 'ui.search.suggestion.contextual.doctor.release_blockers', - fallback: hint + return this.createStrategicSuggestion( + 'ui.search.suggestion.contextual.doctor.release_blockers', + hint ? `release blockers caused by ${hint}` : 'release blockers caused by failing health checks', - }; + ); case 'timeline': - return { - key: 'ui.search.suggestion.contextual.timeline.policy_change', - fallback: hint + return this.createStrategicSuggestion( + 'ui.search.suggestion.contextual.timeline.policy_change', + hint ? `policy or config changes before ${hint}` : 'policy or config changes before recent incidents', - }; + ); case 'releases': - return { - key: 'ui.search.suggestion.contextual.releases.blockers', - fallback: hint + return this.createStrategicSuggestion( + 'ui.search.suggestion.contextual.releases.blockers', + hint ? `what blocked release of ${hint}` : 'what blocked recent promotions', - }; + ); default: - return { - key: 'ui.search.suggestion.contextual.default.delta', - fallback: 'what changed since the last successful promotion', - }; + return this.createStrategicSuggestion( + 'ui.search.suggestion.contextual.default.delta', + 'what changed since the last successful promotion', + ); } } @@ -255,35 +299,105 @@ export class AmbientContextService { const normalizedAction = action.action.trim().toLowerCase(); if (normalizedAction === 'chat_search_for_more' || normalizedAction === 'chat_search_related') { - return { - key: 'ui.search.suggestion.contextual.chat.policy_vex', - fallback: hint + return this.createStrategicSuggestion( + 'ui.search.suggestion.contextual.chat.policy_vex', + hint ? `policy and VEX impact of ${hint}` : 'policy and VEX impact of this issue', - }; + ); } if (normalizedAction === 'search_result_open' || normalizedAction === 'search_result_action') { - return { - key: 'ui.search.suggestion.contextual.search_result.timeline', - fallback: hint + return this.createStrategicSuggestion( + 'ui.search.suggestion.contextual.search_result.timeline', + hint ? `incident timeline and related exposures for ${hint}` : 'incident timeline and related exposures', - }; + ); } if (normalizedAction === 'search_to_chat' || normalizedAction === 'search_to_chat_synthesis') { - return { - key: 'ui.search.suggestion.contextual.search_to_chat.evidence', - fallback: hint + return this.createStrategicSuggestion( + 'ui.search.suggestion.contextual.search_to_chat.evidence', + hint ? `evidence chain and policy rationale for ${hint}` : 'evidence chain and policy rationale', - }; + ); } return null; } + private buildContextPanelTokens( + context: SearchContextDefinition | null, + pageLabel: string, + lastAction: UnifiedSearchAmbientAction | null, + ): readonly SearchContextPanelToken[] { + const tokens: SearchContextPanelToken[] = [ + { + key: 'page', + labelKey: 'ui.search.context.token.page', + labelFallback: 'Page', + value: pageLabel, + }, + ]; + + if (context?.domain) { + tokens.push({ + key: 'scope', + labelKey: 'ui.search.context.token.scope', + labelFallback: 'Scope', + value: DOMAIN_LABELS[context.domain], + }); + } + + const lastActionLabel = lastAction ? this.describeAction(lastAction) : null; + if (lastActionLabel) { + tokens.push({ + key: 'last-action', + labelKey: 'ui.search.context.token.last_action', + labelFallback: 'Last action', + value: lastActionLabel, + }); + } + + return tokens; + } + + private createStrategicSuggestion(key: string, fallback: string): ContextSuggestion { + return { + key, + fallback, + reasonKey: 'ui.search.suggestion.reason.strategy', + reasonFallback: 'Generated from the recent intent on this page.', + kind: 'strategy', + preferredModes: ['explain', 'act'], + }; + } + + private describeAction(action: UnifiedSearchAmbientAction): string | null { + const hint = this.buildActionHint(action); + const normalizedAction = action.action.trim().toLowerCase(); + + switch (normalizedAction) { + case 'chat_search_for_more': + return hint ? `Expanded AdvisoryAI search for ${hint}` : 'Expanded AdvisoryAI search'; + case 'chat_search_related': + return hint ? `Asked AdvisoryAI for related context on ${hint}` : 'Asked AdvisoryAI for related context'; + case 'search_result_open': + return hint ? `Opened result for ${hint}` : 'Opened a search result'; + case 'search_result_action': + return hint ? `Used a result action for ${hint}` : 'Used a search result action'; + case 'search_to_chat': + case 'search_to_chat_synthesis': + return hint ? `Asked AdvisoryAI about ${hint}` : 'Asked AdvisoryAI about a search result'; + default: { + const readableAction = this.humanizeAction(action.action); + return hint ? `${readableAction} for ${hint}` : readableAction; + } + } + } + private resolveDominantAction( actions: readonly UnifiedSearchAmbientAction[], ): UnifiedSearchAmbientAction | null { @@ -480,6 +594,41 @@ export class AmbientContextService { return `/${segments[0]}`; } + private humanizeScope(scope: string): string | null { + const trimmed = scope.trim(); + if (!trimmed) { + return null; + } + + const segments = trimmed + .split('/') + .filter((segment) => segment.length > 0) + .map((segment) => segment.replace(/[-_]/g, ' ')); + + if (segments.length === 0) { + return null; + } + + return segments + .map((segment) => segment.replace(/\b\w/g, (char) => char.toUpperCase())) + .join(' / '); + } + + private humanizeAction(action: string): string { + const trimmed = action.trim(); + if (!trimmed) { + return 'Recent action'; + } + + const words = trimmed + .replace(/[-_]+/g, ' ') + .split(/\s+/) + .filter((part) => part.length > 0) + .map((part) => part.replace(/\b\w/g, (char) => char.toUpperCase())); + + return words.length > 0 ? words.join(' ') : 'Recent action'; + } + private normalizeRoute(route: string): string { if (!route) { return '/'; diff --git a/src/Web/StellaOps.Web/src/app/core/services/search-chat-context.service.ts b/src/Web/StellaOps.Web/src/app/core/services/search-chat-context.service.ts index 3f1ce1427..6478dcae3 100644 --- a/src/Web/StellaOps.Web/src/app/core/services/search-chat-context.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/services/search-chat-context.service.ts @@ -1,19 +1,22 @@ import { Injectable, signal } from '@angular/core'; import { Subject } from 'rxjs'; import type { EntityCard, SynthesisResult, UnifiedSearchDomain } from '../api/unified-search.models'; +import type { SearchExperienceMode } from './search-experience-mode.service'; export interface SearchToChatContext { query: string; entityCards: EntityCard[]; synthesis: SynthesisResult | null; suggestedPrompt?: string; + mode?: SearchExperienceMode; } export interface ChatToSearchContext { query: string; domain?: UnifiedSearchDomain; entityKey?: string; - action?: 'chat_search_for_more' | 'chat_search_related'; + action?: 'chat_search_for_more' | 'chat_search_related' | 'chat_next_step_search' | 'chat_next_step_policy'; + mode?: SearchExperienceMode; } @Injectable({ providedIn: 'root' }) 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 a5046183c..328e99a7c 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,13 +1,28 @@ import type { UnifiedSearchDomain } from '../api/unified-search.models'; +import type { SearchExperienceMode } from './search-experience-mode.service'; + +export type SearchSuggestionKind = 'page' | 'recent' | 'strategy'; export interface SearchSuggestionChip { key: string; fallback: string; + reasonKey?: string; + reasonFallback?: string; + kind?: SearchSuggestionKind; + preferredModes?: readonly SearchExperienceMode[]; +} + +export interface SearchContextPresentation { + titleKey: string; + titleFallback: string; + descriptionKey: string; + descriptionFallback: string; } export interface SearchContextDefinition { id: string; routePrefixes: readonly string[]; + presentation?: SearchContextPresentation; domain?: UnifiedSearchDomain; searchSuggestions?: readonly SearchSuggestionChip[]; chatSuggestions?: readonly SearchSuggestionChip[]; @@ -23,11 +38,24 @@ export interface SearchContextComponent { readonly searchContextId: string; } -export const DEFAULT_SEARCH_SUGGESTIONS: readonly SearchSuggestionChip[] = [ - { 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' }, -]; +function withReason( + suggestions: readonly SearchSuggestionChip[], + reasonKey: string, + reasonFallback: string, +): readonly SearchSuggestionChip[] { + return suggestions.map((suggestion) => ({ + ...suggestion, + reasonKey, + reasonFallback, + kind: suggestion.kind ?? 'page', + })); +} + +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'] }, +], 'ui.search.suggestion.reason.default', 'Useful starting points across Stella Ops.'); export const DEFAULT_CHAT_SUGGESTIONS: readonly SearchSuggestionChip[] = [ { key: 'ui.chat.suggestion.default.what_can_do', fallback: 'What can Stella Ops do?' }, @@ -36,35 +64,35 @@ export const DEFAULT_CHAT_SUGGESTIONS: readonly SearchSuggestionChip[] = [ { key: 'ui.chat.suggestion.default.health_checks', fallback: 'What health checks should I run first?' }, ]; -const FINDINGS_SEARCH_SUGGESTIONS: readonly SearchSuggestionChip[] = [ - { 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' }, -]; +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'] }, +], 'ui.search.suggestion.reason.findings', 'Common triage pivots for the current findings workspace.'); -const POLICY_SEARCH_SUGGESTIONS: readonly SearchSuggestionChip[] = [ - { 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' }, -]; +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'] }, +], 'ui.search.suggestion.reason.policy', 'High-signal policy investigations for this page.'); -const DOCTOR_SEARCH_SUGGESTIONS: readonly SearchSuggestionChip[] = [ - { 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' }, -]; +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'] }, +], 'ui.search.suggestion.reason.doctor', 'Fast operational checks for the current health view.'); -const TIMELINE_SEARCH_SUGGESTIONS: readonly SearchSuggestionChip[] = [ - { 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' }, -]; +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'] }, +], 'ui.search.suggestion.reason.timeline', 'Frequent pivots for promotion and incident analysis.'); -const RELEASES_SEARCH_SUGGESTIONS: readonly SearchSuggestionChip[] = [ - { 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' }, -]; +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'] }, +], 'ui.search.suggestion.reason.releases', 'Common release-investigation pivots for this workflow.'); const FINDINGS_CHAT_SUGGESTIONS: readonly SearchSuggestionChip[] = [ { key: 'ui.chat.suggestion.vulnerability.exploitable', fallback: 'Is this exploitable in my environment?' }, @@ -84,6 +112,12 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [ { id: 'findings', routePrefixes: ['/security/triage', '/security/findings'], + presentation: { + titleKey: 'ui.search.context.findings.title', + titleFallback: 'Findings triage', + descriptionKey: 'ui.search.context.findings.description', + descriptionFallback: 'Investigate live findings, reachability, and remediation evidence.', + }, domain: 'findings', searchSuggestions: FINDINGS_SEARCH_SUGGESTIONS, }, @@ -96,11 +130,23 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [ { id: 'vex', routePrefixes: ['/security/advisories-vex', '/vex-hub'], + presentation: { + titleKey: 'ui.search.context.vex.title', + titleFallback: 'VEX intelligence', + descriptionKey: 'ui.search.context.vex.description', + descriptionFallback: 'Search vendor statements, affected ranges, and disposition evidence.', + }, domain: 'vex', }, { id: 'policy', routePrefixes: ['/ops/policy'], + presentation: { + titleKey: 'ui.search.context.policy.title', + titleFallback: 'Policy workspace', + descriptionKey: 'ui.search.context.policy.description', + descriptionFallback: 'Review rules, failures, and their impact on active findings.', + }, domain: 'policy', searchSuggestions: POLICY_SEARCH_SUGGESTIONS, chatSuggestions: POLICY_CHAT_SUGGESTIONS, @@ -108,28 +154,58 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [ { id: 'doctor', routePrefixes: ['/ops/operations/doctor', '/ops/operations/system-health'], + presentation: { + titleKey: 'ui.search.context.doctor.title', + titleFallback: 'Doctor diagnostics', + descriptionKey: 'ui.search.context.doctor.description', + descriptionFallback: 'Investigate health checks and release readiness blockers.', + }, domain: 'knowledge', searchSuggestions: DOCTOR_SEARCH_SUGGESTIONS, }, { id: 'graph', routePrefixes: ['/ops/graph', '/security/reach'], + presentation: { + titleKey: 'ui.search.context.graph.title', + titleFallback: 'Graph explorer', + descriptionKey: 'ui.search.context.graph.description', + descriptionFallback: 'Follow dependency and reachability paths across the platform.', + }, domain: 'graph', }, { id: 'ops-memory', routePrefixes: ['/ops/operations/jobs', '/ops/operations/scheduler'], + presentation: { + titleKey: 'ui.search.context.ops_memory.title', + titleFallback: 'Operations memory', + descriptionKey: 'ui.search.context.ops_memory.description', + descriptionFallback: 'Search recurring incidents, jobs, and learned operator runbooks.', + }, domain: 'ops_memory', }, { id: 'timeline', routePrefixes: ['/ops/timeline', '/audit'], + presentation: { + titleKey: 'ui.search.context.timeline.title', + titleFallback: 'Timeline analysis', + descriptionKey: 'ui.search.context.timeline.description', + descriptionFallback: 'Trace changes, incidents, and promotion history.', + }, domain: 'timeline', searchSuggestions: TIMELINE_SEARCH_SUGGESTIONS, }, { id: 'releases', routePrefixes: ['/releases', '/mission-control'], + presentation: { + titleKey: 'ui.search.context.releases.title', + titleFallback: 'Release control', + descriptionKey: 'ui.search.context.releases.description', + descriptionFallback: 'Investigate promotions, approvals, and blockers.', + }, searchSuggestions: RELEASES_SEARCH_SUGGESTIONS, }, ] 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 new file mode 100644 index 000000000..d8d6e7da4 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/services/search-experience-mode.service.ts @@ -0,0 +1,110 @@ +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/features/advisory-ai/chat/chat-message.component.ts b/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat-message.component.ts index 959e40729..3a1d6af7d 100644 --- a/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat-message.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat-message.component.ts @@ -5,9 +5,13 @@ // ----------------------------------------------------------------------------- import { Component, Input, Output, EventEmitter, computed, inject, signal } from '@angular/core'; +import { Router } from '@angular/router'; import { ConversationTurn, + EvidenceCitation, + getObjectLinkUrl, + OBJECT_LINK_METADATA, ParsedObjectLink, ProposedAction, parseObjectLinks, @@ -15,6 +19,8 @@ import { import { ObjectLinkChipComponent } from './object-link-chip.component'; import { ActionButtonComponent } from './action-button.component'; import { SearchChatContextService } from '../../../core/services/search-chat-context.service'; +import { SearchExperienceModeService, type SearchExperienceMode } from '../../../core/services/search-experience-mode.service'; +import type { UnifiedSearchDomain } from '../../../core/api/unified-search.models'; interface MessageSegment { type: 'text' | 'link'; @@ -22,6 +28,20 @@ interface MessageSegment { link?: ParsedObjectLink; } +interface NextStepCard { + id: 'search' | 'evidence' | 'policy' | 'context'; + title: string; + description: string; + actionLabel: string; + evidenceLabel: string; + actionType: 'search' | 'navigate'; + query?: string; + route?: string; + domain?: UnifiedSearchDomain; + entityKey?: string; + chatAction?: 'chat_next_step_search' | 'chat_next_step_policy'; +} + /** * Renders a single chat message (turn) with markdown support, * object link chips, and action buttons. @@ -142,6 +162,30 @@ interface MessageSegment { } + @if (nextStepCards().length > 0) { +
+
+ Suggested next steps + {{ currentModeLabel() }} +
+
+ @for (card of nextStepCards(); track card.id) { + + } +
+
+ } + @if (turn.proposedActions && turn.proposedActions.length > 0) {
@@ -394,6 +438,89 @@ interface MessageSegment { flex-shrink: 0; } + .next-steps { + margin-top: 14px; + padding: 12px; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--color-surface-primary) 0%, var(--color-surface-tertiary) 100%); + } + + .next-steps__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.75rem; + } + + .next-steps__title { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + } + + .next-steps__mode { + font-size: var(--font-size-xs); + color: var(--color-brand-primary, #2563eb); + background: var(--color-brand-primary-10, #eff6ff); + border-radius: 999px; + padding: 0.1875rem 0.5rem; + } + + .next-steps__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.625rem; + } + + .next-step-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.3125rem; + padding: 0.75rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-primary); + text-align: left; + cursor: pointer; + transition: border-color 0.12s, background-color 0.12s, transform 0.12s; + } + + .next-step-card:hover { + border-color: var(--color-brand-primary, #2563eb); + background: var(--color-brand-primary-10, #eff6ff); + transform: translateY(-1px); + } + + .next-step-card__eyebrow { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-text-secondary); + } + + .next-step-card__title { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + } + + .next-step-card__description { + font-size: var(--font-size-sm); + line-height: 1.4; + color: var(--color-text-muted); + } + + .next-step-card__cta { + margin-top: auto; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--color-brand-primary, #2563eb); + } + .message-actions { display: flex; flex-wrap: wrap; @@ -431,6 +558,12 @@ interface MessageSegment { width: 16px; height: 16px; } + + @media (max-width: 767px) { + .next-steps__grid { + grid-template-columns: 1fr; + } + } `], }) export class ChatMessageComponent { @@ -439,12 +572,17 @@ export class ChatMessageComponent { @Output() actionExecute = new EventEmitter(); @Output() searchForMore = new EventEmitter(); + private readonly router = inject(Router); private readonly searchChatContext = inject(SearchChatContextService); + private readonly searchExperienceMode = inject(SearchExperienceModeService); readonly showCitations = signal(false); readonly copied = signal(false); - + readonly currentMode = this.searchExperienceMode.mode; + readonly currentModeLabel = computed(() => + `${this.searchExperienceMode.definition().labelFallback} mode`); readonly segments = computed(() => this.parseContent(this.turn.content)); + readonly nextStepCards = computed(() => this.buildNextStepCards()); /** * Parses message content into text and link segments. @@ -564,6 +702,7 @@ export class ChatMessageComponent { domain, entityKey: firstCitation?.path, action: 'chat_search_for_more', + mode: this.searchExperienceMode.currentMode(), }); this.searchForMore.emit(query); } @@ -576,10 +715,29 @@ export class ChatMessageComponent { domain, entityKey: citation.path, action: 'chat_search_related', + mode: this.searchExperienceMode.currentMode(), }); this.searchForMore.emit(query); } + onNextStep(card: NextStepCard): void { + if (card.actionType === 'search' && card.query) { + this.searchChatContext.setChatToSearch({ + query: card.query, + domain: card.domain, + entityKey: card.entityKey, + action: card.chatAction, + mode: this.searchExperienceMode.currentMode(), + }); + this.searchForMore.emit(card.query); + return; + } + + if (card.actionType === 'navigate' && card.route) { + void this.router.navigateByUrl(card.route); + } + } + private extractSearchQuery(content: string): string { // Extract CVE IDs if present const cveRegex = /CVE-\d{4}-\d{4,}/gi; @@ -620,7 +778,286 @@ export class ChatMessageComponent { : normalizedPath; } - private mapCitationTypeToDomain(type: string): 'knowledge' | 'findings' | 'vex' | 'policy' | 'platform' | undefined { + private buildNextStepCards(): NextStepCard[] { + if (this.turn.role !== 'assistant') { + return []; + } + + const citations = this.turn.citations ?? []; + if (citations.length === 0) { + return []; + } + + const mode = this.searchExperienceMode.currentMode(); + const primaryCitation = this.pickPrimaryCitation(citations); + const baseQuery = primaryCitation + ? this.extractSearchQueryFromCitation(primaryCitation.type, primaryCitation.path) + : this.extractSearchQuery(this.turn.content); + + const cards: NextStepCard[] = []; + const evidenceCard = this.buildEvidenceCard(primaryCitation); + if (evidenceCard) { + cards.push(evidenceCard); + } + + const searchCard = this.buildSearchCard(baseQuery, primaryCitation, mode); + if (searchCard) { + cards.push(searchCard); + } + + const policyCard = this.buildPolicyCard(baseQuery, primaryCitation, mode); + if (policyCard) { + cards.push(policyCard); + } + + const contextCard = this.buildContextCard(citations, evidenceCard?.route ?? null); + if (contextCard) { + cards.push(contextCard); + } + + return cards + .filter((card, index, allCards) => + allCards.findIndex((candidate) => candidate.id === card.id) === index) + .sort((left, right) => + this.scoreNextStepCard(right, mode) - this.scoreNextStepCard(left, mode)) + .slice(0, 4); + } + + private buildSearchCard( + query: string, + citation: EvidenceCitation | null, + mode: SearchExperienceMode, + ): NextStepCard | null { + const normalizedQuery = this.buildModeAwareNextSearchQuery(query); + if (!normalizedQuery) { + return null; + } + + const evidenceLabel = citation + ? this.buildEvidenceLabel(citation) + : 'Evidence-guided search'; + + switch (mode) { + case 'explain': + return { + id: 'search', + title: 'Explain related evidence', + description: 'Search for the strongest supporting evidence and clarifying context.', + actionLabel: 'Search explanation', + evidenceLabel, + actionType: 'search', + query: normalizedQuery, + domain: citation ? this.mapCitationTypeToDomain(citation.type) : undefined, + entityKey: citation?.path, + chatAction: 'chat_next_step_search', + }; + case 'act': + return { + id: 'search', + title: 'Find the next operator step', + description: 'Search for mitigations, blockers, and execution guidance tied to this answer.', + actionLabel: 'Search next step', + evidenceLabel, + actionType: 'search', + query: normalizedQuery, + domain: citation ? this.mapCitationTypeToDomain(citation.type) : undefined, + entityKey: citation?.path, + chatAction: 'chat_next_step_search', + }; + default: + return { + id: 'search', + title: 'Search deeper', + description: 'Pull more related results from the strongest cited entity.', + actionLabel: 'Search deeper', + evidenceLabel, + actionType: 'search', + query: normalizedQuery, + domain: citation ? this.mapCitationTypeToDomain(citation.type) : undefined, + entityKey: citation?.path, + chatAction: 'chat_next_step_search', + }; + } + } + + private buildPolicyCard( + query: string, + citation: EvidenceCitation | null, + mode: SearchExperienceMode, + ): NextStepCard | null { + const policyQuery = this.buildPolicyQuery(query); + if (!policyQuery) { + return null; + } + + const evidenceLabel = citation + ? `${this.getCitationLabel(citation)} policy` + : 'Policy pivot'; + + switch (mode) { + case 'act': + return { + id: 'policy', + title: 'Check policy blockers', + description: 'Pivot into policy gates, exceptions, and approval blockers connected to this answer.', + actionLabel: 'Search blockers', + evidenceLabel, + actionType: 'search', + query: policyQuery, + domain: 'policy', + entityKey: citation?.path, + chatAction: 'chat_next_step_policy', + }; + case 'find': + return { + id: 'policy', + title: 'Find policy matches', + description: 'Search for the rules and gates most closely related to this evidence.', + actionLabel: 'Search policy', + evidenceLabel, + actionType: 'search', + query: policyQuery, + domain: 'policy', + entityKey: citation?.path, + chatAction: 'chat_next_step_policy', + }; + default: + return { + id: 'policy', + title: 'Compare policy impact', + description: 'Search for the policy meaning and release impact behind this answer.', + actionLabel: 'Search impact', + evidenceLabel, + actionType: 'search', + query: policyQuery, + domain: 'policy', + entityKey: citation?.path, + chatAction: 'chat_next_step_policy', + }; + } + } + + private buildEvidenceCard(citation: EvidenceCitation | null): NextStepCard | null { + if (!citation) { + return null; + } + + const route = this.resolveCitationRoute(citation); + if (!route) { + return null; + } + + return { + id: 'evidence', + title: 'Inspect evidence chain', + description: 'Open the strongest cited evidence directly and verify the provenance yourself.', + actionLabel: 'Open evidence', + evidenceLabel: this.buildEvidenceLabel(citation), + actionType: 'navigate', + route, + }; + } + + private buildContextCard( + citations: readonly EvidenceCitation[], + evidenceRoute: string | null, + ): NextStepCard | null { + const contextCitation = citations.find((citation) => { + const route = this.resolveCitationRoute(citation); + return !!route && route !== evidenceRoute; + }); + + if (!contextCitation) { + return null; + } + + const route = this.resolveCitationRoute(contextCitation); + if (!route) { + return null; + } + + return { + id: 'context', + title: 'Open related context', + description: `Jump to the cited ${this.getCitationLabel(contextCitation).toLowerCase()} context linked to this answer.`, + actionLabel: 'Open context', + evidenceLabel: `${this.getCitationLabel(contextCitation)} context`, + actionType: 'navigate', + route, + }; + } + + private pickPrimaryCitation(citations: readonly EvidenceCitation[]): EvidenceCitation | null { + return citations.find((citation) => citation.verified) ?? citations[0] ?? null; + } + + private buildModeAwareNextSearchQuery(query: string): string { + const normalized = query.trim(); + if (!normalized) { + return ''; + } + + switch (this.searchExperienceMode.currentMode()) { + case 'explain': + return `why ${normalized} matters`; + case 'act': + return `next step for ${normalized}`; + default: + return normalized; + } + } + + private buildPolicyQuery(query: string): string { + const normalized = query.trim(); + if (!normalized) { + return ''; + } + + switch (this.searchExperienceMode.currentMode()) { + case 'act': + return `policy blockers for ${normalized}`; + case 'explain': + return `policy impact of ${normalized}`; + default: + return `policy rules for ${normalized}`; + } + } + + private scoreNextStepCard(card: NextStepCard, mode: SearchExperienceMode): number { + switch (card.id) { + case 'evidence': + return 100; + case 'search': + return mode === 'find' ? 65 : mode === 'act' ? 58 : 46; + case 'policy': + return mode === 'act' ? 62 : mode === 'explain' ? 60 : 42; + case 'context': + return mode === 'explain' ? 52 : 44; + default: + return 0; + } + } + + private resolveCitationRoute(citation: EvidenceCitation): string | null { + const resolvedUrl = citation.resolvedUrl?.trim(); + if (resolvedUrl) { + return resolvedUrl; + } + + const route = getObjectLinkUrl(this.citationToLink(citation)); + return route && route !== '#' ? route : null; + } + + private getCitationLabel(citation: { type: string }): string { + return OBJECT_LINK_METADATA[citation.type as keyof typeof OBJECT_LINK_METADATA]?.label ?? citation.type; + } + + private buildEvidenceLabel(citation: EvidenceCitation): string { + const label = this.getCitationLabel(citation); + return citation.verified ? `Verified ${label}` : `${label} citation`; + } + + private mapCitationTypeToDomain(type: string): UnifiedSearchDomain | undefined { switch (type) { case 'docs': return 'knowledge'; diff --git a/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat.component.ts b/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat.component.ts index a89a83fec..c91f674e1 100644 --- a/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat.component.ts @@ -24,6 +24,10 @@ import { Subject, takeUntil } from 'rxjs'; import { ChatService } from './chat.service'; import { ChatMessageComponent } from './chat-message.component'; import { AmbientContextService } from '../../../core/services/ambient-context.service'; +import { + SearchExperienceModeService, + type SearchExperienceMode, +} from '../../../core/services/search-experience-mode.service'; import { I18nService } from '../../../core/i18n'; import { Conversation, @@ -48,16 +52,32 @@ import {
- - - - - - -

AdvisoryAI

- @if (conversation()) { - {{ conversation()!.conversationId.substring(0, 8) }} - } +
+ + + + + + +

AdvisoryAI

+ @if (conversation()) { + {{ conversation()!.conversationId.substring(0, 8) }} + } +
+
+ @for (mode of experienceModeOptions(); track mode.id) { + + } +
@if (conversation()) { @@ -109,7 +129,7 @@ import {

Ask AdvisoryAI

-

Ask questions about vulnerabilities, exploitability, remediation, or integrations.

+

{{ modeEmptyStateDescription() }}

@for (suggestion of suggestions(); track suggestion) {
} +
+
+
{{ t('ui.search.mode.label', 'Mode') }}
+
{{ experienceModeDescription() }}
+
+
+
+ @for (mode of experienceModeOptions(); track mode.id) { + + } +
+ +
+
+ @if (isLoading()) {
{{ t('ui.search.loading', 'Searching...') }}
} @else if (query().trim().length >= 1 && cards().length === 0) { @@ -107,6 +164,22 @@ type SearchDomainFilter = 'all' | UnifiedSearchDomain; }
} +
+
{{ t('ui.search.rescue.label', 'Recover this search') }}
+
+ @for (action of rescueActions(); track action.id) { + + } +
+
} @else if (query().trim().length >= 1) {
@for (filter of availableDomainFilters(); track filter) { @@ -195,15 +268,40 @@ type SearchDomainFilter = 'all' | UnifiedSearchDomain;
} + @if (searchContextPanel(); as contextPanel) { +
+
{{ t('ui.search.context.label', 'Context') }}
+
+
{{ contextPanel.title }}
+
{{ contextPanel.description }}
+
+ @for (token of contextPanel.tokens; track token.key) { + + {{ token.label }}: + {{ token.value }} + + } +
+
+
+ } +
{{ t('ui.search.suggested_label', 'Suggested') }}
-
- @for (suggestion of contextualSuggestions(); track suggestion) { - +
+ @for (suggestion of contextualSuggestions(); track suggestion.query) { +
+ +
{{ suggestion.reason }}
+
}
@@ -381,6 +479,93 @@ type SearchDomainFilter = 'all' | UnifiedSearchDomain; margin-right: 0.25rem; } + .search__experience-bar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem; + border-bottom: 1px solid var(--color-border-primary); + background: linear-gradient(180deg, var(--color-surface-primary) 0%, var(--color-surface-secondary) 100%); + } + + .search__experience-copy { + min-width: 0; + } + + .search__experience-title { + font-size: 0.6875rem; + font-weight: var(--font-weight-semibold); + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--color-text-secondary); + } + + .search__experience-description { + margin-top: 0.1875rem; + font-size: 0.75rem; + line-height: 1.35; + color: var(--color-text-muted); + max-width: 18rem; + } + + .search__experience-controls { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + justify-content: flex-end; + } + + .search__segmented { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.1875rem; + border: 1px solid var(--color-border-primary); + border-radius: 999px; + background: var(--color-surface-tertiary); + } + + .search__segment { + border: none; + background: transparent; + color: var(--color-text-secondary); + border-radius: 999px; + padding: 0.25rem 0.625rem; + font-size: 0.75rem; + cursor: pointer; + transition: background-color 0.12s, color 0.12s; + } + + .search__segment:hover { + color: var(--color-text-primary); + background: var(--color-nav-hover); + } + + .search__segment--active { + background: var(--color-brand-primary-10, #eff6ff); + color: var(--color-brand-primary, #1d4ed8); + font-weight: var(--font-weight-semibold); + } + + .search__scope-chip { + border: 1px solid var(--color-border-secondary); + background: transparent; + color: var(--color-text-secondary); + border-radius: 999px; + padding: 0.25rem 0.625rem; + font-size: 0.75rem; + cursor: pointer; + transition: border-color 0.12s, color 0.12s, background-color 0.12s; + } + + .search__scope-chip:hover { + border-color: var(--color-brand-primary, #1d4ed8); + color: var(--color-brand-primary, #1d4ed8); + background: var(--color-brand-primary-10, #eff6ff); + } + .search__cards { padding: 0.25rem 0; } @@ -438,15 +623,65 @@ type SearchDomainFilter = 'all' | UnifiedSearchDomain; text-overflow: ellipsis; } + .search__context-rail { + padding: 0.5rem 0; + border-bottom: 1px solid var(--color-border-primary); + } + + .search__context-card { + margin: 0.25rem 0.75rem 0; + padding: 0.625rem 0.75rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: linear-gradient(135deg, var(--color-surface-primary) 0%, var(--color-surface-tertiary) 100%); + } + + .search__context-title { + font-size: 0.8125rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + } + + .search__context-description { + margin-top: 0.1875rem; + font-size: 0.6875rem; + line-height: 1.35; + color: var(--color-text-muted); + } + + .search__context-tokens { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-top: 0.5rem; + } + + .search__context-token { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.1875rem 0.5rem; + border-radius: 999px; + border: 1px solid var(--color-border-secondary); + background: var(--color-surface-primary); + font-size: 0.625rem; + color: var(--color-text-secondary); + } + + .search__context-token-label { + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + } + .search__suggestions { padding: 0.5rem 0; border-bottom: 1px solid var(--color-border-primary); } - .search__suggestion-chips { - display: flex; - flex-wrap: wrap; - gap: 0.375rem; + .search__suggestion-cards { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; padding: 0.25rem 0.75rem; } @@ -469,6 +704,41 @@ type SearchDomainFilter = 'all' | UnifiedSearchDomain; color: var(--color-text-primary); } + .search__suggestion-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.375rem; + padding: 0.5rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: var(--color-surface-primary); + } + + .search__suggestion-card--recent { + border-color: var(--color-brand-primary-20, #bfdbfe); + background: linear-gradient(180deg, var(--color-brand-primary-10, #eff6ff) 0%, var(--color-surface-primary) 100%); + } + + .search__suggestion-card--strategy { + border-style: dashed; + } + + .search__chip--contextual { + font-family: var(--font-family-mono); + font-size: 0.6875rem; + line-height: 1.25; + white-space: normal; + text-align: left; + max-width: 100%; + } + + .search__suggestion-reason { + font-size: 0.6875rem; + line-height: 1.35; + color: var(--color-text-muted); + } + .search__chip--example { font-family: var(--font-family-mono); font-size: 0.625rem; @@ -494,6 +764,23 @@ type SearchDomainFilter = 'all' | UnifiedSearchDomain; } @media (max-width: 767px) { + .search__experience-bar { + flex-direction: column; + } + + .search__experience-controls { + width: 100%; + justify-content: flex-start; + } + + .search__rescue-actions { + grid-template-columns: 1fr; + } + + .search__suggestion-cards { + grid-template-columns: 1fr; + } + .search__domain-grid { grid-template-columns: 1fr; } @@ -626,6 +913,50 @@ type SearchDomainFilter = 'all' | UnifiedSearchDomain; color: #0c4a6e; } + .search__rescue { + padding: 0.5rem 0; + border-top: 1px solid var(--color-border-primary); + } + + .search__rescue-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; + padding: 0.25rem 0.75rem; + } + + .search__rescue-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + padding: 0.625rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: var(--color-surface-primary); + text-align: left; + cursor: pointer; + transition: border-color 0.12s, background-color 0.12s, transform 0.12s; + } + + .search__rescue-card:hover { + border-color: var(--color-brand-primary, #1d4ed8); + background: var(--color-brand-primary-10, #eff6ff); + transform: translateY(-1px); + } + + .search__rescue-title { + font-size: 0.75rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + } + + .search__rescue-description { + font-size: 0.6875rem; + line-height: 1.35; + color: var(--color-text-muted); + } + @media (prefers-reduced-motion: reduce) { .search__input-wrapper { transition: none; @@ -646,15 +977,23 @@ type SearchDomainFilter = 'all' | UnifiedSearchDomain; .try-also-bar__chip { transition: none; } + + .search__segment, + .search__scope-chip, + .search__rescue-card { + transition: none; + } } `], changeDetection: ChangeDetectionStrategy.OnPush, }) export class GlobalSearchComponent implements OnInit, OnDestroy { + private readonly hostElement = inject(ElementRef); private readonly router = inject(Router); private readonly searchClient = inject(UnifiedSearchClient); private readonly ambientContext = inject(AmbientContextService); private readonly searchChatContext = inject(SearchChatContextService); + private readonly searchExperienceMode = inject(SearchExperienceModeService); private readonly i18n = inject(I18nService); private readonly destroy$ = new Subject(); private readonly searchTerms$ = new Subject(); @@ -679,6 +1018,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { readonly searchResponse = signal(null); readonly recentSearches = signal([]); readonly activeDomainFilter = signal('all'); + readonly searchScope = signal('page'); readonly expandedCardKey = signal(null); readonly pendingDomainFilter = signal(null); @@ -704,6 +1044,24 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { return this.i18n.tryT('ui.search.degraded.results') ?? 'Showing legacy fallback results. Coverage and ranking may differ until unified search recovers.'; }); + readonly experienceMode = this.searchExperienceMode.mode; + readonly experienceModeDefinition = this.searchExperienceMode.definition; + readonly experienceModeDescription = computed(() => + this.t( + this.experienceModeDefinition().descriptionKey, + this.experienceModeDefinition().descriptionFallback, + )); + readonly experienceModeOptions = computed(() => + this.searchExperienceMode.definitions.map((mode) => ({ + id: mode.id, + label: this.t(mode.labelKey, mode.labelFallback), + description: this.t(mode.descriptionKey, mode.descriptionFallback), + }))); + readonly searchScopeLabel = computed(() => + this.searchScope() === 'page' + ? this.t('ui.search.scope.page', 'This page') + : this.t('ui.search.scope.global', 'All domains'), + ); private readonly domainGuideCatalog = [ { @@ -797,10 +1155,89 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { example: this.t(domain.exampleKey, domain.exampleFallback), }))); - readonly contextualSuggestions = computed(() => - this.ambientContext - .getSearchSuggestions() - .map((suggestion) => this.i18n.tryT(suggestion.key) ?? suggestion.fallback)); + readonly searchContextPanel = computed(() => { + const panel = this.ambientContext.getSearchContextPanel(); + if (!panel) { + return null; + } + + return { + title: this.t(panel.titleKey, panel.titleFallback), + description: this.t(panel.descriptionKey, panel.descriptionFallback), + tokens: panel.tokens.map((token) => ({ + key: token.key, + label: this.t(token.labelKey, token.labelFallback), + value: token.value, + })), + }; + }); + + readonly contextualSuggestions = computed(() => { + const mode = this.experienceMode(); + return [...this.ambientContext.getSearchSuggestions()] + .sort((left, right) => + this.scoreSuggestionForMode(right, mode) - this.scoreSuggestionForMode(left, mode)) + .map((suggestion) => ({ + query: this.i18n.tryT(suggestion.key) ?? suggestion.fallback, + reason: this.resolveSuggestionReason(suggestion), + kind: suggestion.kind ?? 'page', + preferredModes: suggestion.preferredModes, + })); + }); + + readonly rescueActions = computed(() => { + const query = this.query().trim(); + const pageLabel = this.searchContextPanel()?.title ?? 'current page'; + const alternateQuery = this.buildModeAwareAlternativeQuery(); + const actions: RescueActionView[] = [ + { + id: 'scope', + label: this.searchScope() === 'page' + ? this.t('ui.search.rescue.scope_global', 'Broaden to all domains') + : this.t('ui.search.rescue.scope_page', 'Return to page scope'), + description: this.searchScope() === 'page' + ? this.t('ui.search.rescue.scope_global.description', 'Retry the same query without the current page filter.') + : this.t('ui.search.rescue.scope_page.description', 'Retry the same query with the current page as the focus.'), + }, + { + id: 'related', + label: this.t('ui.search.rescue.related', 'Search a related angle'), + description: alternateQuery + ? this.t('ui.search.rescue.related.description', 'Pivot to a related query chosen for the current mode.') + : this.t('ui.search.rescue.related.none', 'No related pivot is available yet for this page.'), + }, + { + id: 'reformulate', + label: this.t('ui.search.rescue.reformulate', 'Ask AdvisoryAI to reformulate'), + description: this.t( + 'ui.search.rescue.reformulate.description', + 'Open AdvisoryAI with the current query, page context, and active mode.', + ), + }, + { + id: 'page-context', + label: this.t('ui.search.rescue.page_context', 'Retry with page context'), + description: this.t( + 'ui.search.rescue.page_context.description', + 'Blend the current query with the active page context before retrying.', + ), + }, + ]; + + if (!query) { + return actions.filter((action) => action.id !== 'page-context'); + } + + if (!alternateQuery) { + return actions.filter((action) => action.id !== 'related'); + } + + if (!pageLabel.trim()) { + return actions.filter((action) => action.id !== 'page-context'); + } + + return actions; + }); readonly inputPlaceholder = computed(() => { const suggestions = this.contextualSuggestions(); @@ -810,7 +1247,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { const index = this.placeholderIndex() % suggestions.length; return this.t('ui.search.placeholder.try', 'Try: {suggestion}', { - suggestion: suggestions[index], + suggestion: suggestions[index]?.query ?? '', }); }); @@ -864,7 +1301,6 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { this.searchTerms$ .pipe( debounceTime(200), - distinctUntilChanged(), switchMap((term) => { if (term.length < 1) { this.searchResponse.set(null); @@ -875,7 +1311,9 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { } this.isLoading.set(true); - const contextFilter = this.ambientContext.buildContextFilter(); + const contextFilter = this.searchScope() === 'page' + ? this.ambientContext.buildContextFilter() + : undefined; const ambient = this.buildAmbientSnapshot(); return this.searchClient.search(term, contextFilter, 10, ambient).pipe( catchError(() => @@ -957,10 +1395,14 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { this.blurHideHandle = setTimeout(() => { this.blurHideHandle = null; const input = this.searchInputRef?.nativeElement; + const host = this.hostElement.nativeElement; const activeElement = typeof document !== 'undefined' ? document.activeElement : null; - if (input && activeElement === input) { + if ( + (input && activeElement === input) + || (activeElement instanceof HTMLElement && host.contains(activeElement)) + ) { return; } this.isFocused.set(false); @@ -1076,6 +1518,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { entityCards: [card], synthesis: this.synthesis(), suggestedPrompt: askPrompt, + mode: this.experienceMode(), }); this.closeResults(); void this.router.navigate(['/security/triage'], { @@ -1094,6 +1537,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { entityCards: this.filteredCards(), synthesis: this.synthesis(), suggestedPrompt: askPrompt, + mode: this.experienceMode(), }); this.closeResults(); void this.router.navigate(['/security/triage'], { queryParams: { openChat: 'true', q: this.query() } }); @@ -1184,6 +1628,80 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { void this.router.navigateByUrl(route); } + setSearchMode(mode: SearchExperienceMode): void { + if (this.experienceMode() === mode) { + return; + } + + this.searchExperienceMode.setMode(mode); + this.recordAmbientAction('search_mode_switch', { + source: 'global_search_mode_toggle', + queryHint: mode, + }); + } + + toggleSearchScope(): void { + const nextScope: SearchScopeMode = this.searchScope() === 'page' ? 'global' : 'page'; + this.searchScope.set(nextScope); + this.recordAmbientAction('search_scope_toggle', { + source: 'global_search_scope_toggle', + queryHint: nextScope, + }); + + if (this.query().trim().length > 0) { + this.searchTerms$.next(this.query().trim()); + } + } + + runRescueAction(actionId: RescueActionView['id']): void { + const query = this.query().trim(); + if (!query && actionId !== 'reformulate') { + return; + } + + switch (actionId) { + case 'scope': + this.searchScope.set(this.searchScope() === 'page' ? 'global' : 'page'); + this.recordAmbientAction('search_rescue_scope', { + source: 'global_search_rescue', + queryHint: this.searchScope(), + }); + this.searchTerms$.next(query); + return; + case 'related': { + const alternateQuery = this.buildModeAwareAlternativeQuery(); + if (!alternateQuery) { + return; + } + + this.recordAmbientAction('search_rescue_related_domains', { + source: 'global_search_rescue', + queryHint: alternateQuery, + }); + this.query.set(alternateQuery); + this.searchTerms$.next(alternateQuery.trim()); + return; + } + case 'reformulate': + this.openAssistantForReformulation(); + return; + case 'page-context': { + const contextualQuery = this.buildPageContextRetryQuery(); + if (!contextualQuery) { + return; + } + + this.recordAmbientAction('search_rescue_page_context', { + source: 'global_search_rescue', + queryHint: contextualQuery, + }); + this.query.set(contextualQuery); + this.searchTerms$.next(contextualQuery.trim()); + return; + } + } + } + setDomainFilter(filter: SearchDomainFilter): void { this.activeDomainFilter.set(filter); this.selectedIndex.set(0); @@ -1406,6 +1924,10 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { return; } + if (context.mode) { + this.searchExperienceMode.setMode(context.mode); + } + const query = context.query.trim(); this.query.set(query); this.selectedIndex.set(0); @@ -1432,6 +1954,107 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { }); } + private scoreSuggestionForMode( + suggestion: { + kind?: 'page' | 'recent' | 'strategy'; + preferredModes?: readonly SearchExperienceMode[]; + }, + mode: SearchExperienceMode, + ): number { + let score = 0; + if (suggestion.preferredModes?.includes(mode)) { + score += 6; + } + + if (mode === 'find' && suggestion.kind === 'page') { + score += 2; + } + + if (mode === 'explain' && suggestion.kind === 'strategy') { + score += 3; + } + + if (mode === 'act' && suggestion.kind === 'recent') { + score += 2; + } + + return score; + } + + private buildModeAwareAlternativeQuery(): string | null { + const currentQuery = this.query().trim().toLowerCase(); + const mode = this.experienceMode(); + const candidates = this.contextualSuggestions() + .filter((suggestion) => suggestion.query.trim().toLowerCase() !== currentQuery) + .sort((left, right) => { + const leftScore = this.scoreSuggestionForMode(left, mode); + const rightScore = this.scoreSuggestionForMode(right, mode); + return rightScore - leftScore; + }); + + return candidates[0]?.query ?? null; + } + + private buildPageContextRetryQuery(): string | null { + const query = this.query().trim(); + const pageLabel = this.searchContextPanel()?.title?.trim(); + if (!query || !pageLabel) { + return null; + } + + return `${pageLabel} ${query}`.trim(); + } + + private openAssistantForReformulation(): void { + const query = this.query().trim(); + const pageLabel = this.searchContextPanel()?.title ?? 'current page'; + const directive = this.searchExperienceMode.definition().assistantDirective; + const suggestedPrompt = query + ? `${directive} Reformulate the search query "${query}" for ${pageLabel} and explain why the reformulation is better.` + : `${directive} Help me frame a better search for ${pageLabel}.`; + + this.recordAmbientAction('search_rescue_reformulate', { + source: 'global_search_rescue', + queryHint: query || pageLabel, + }); + this.searchChatContext.setSearchToChat({ + query: query || pageLabel, + entityCards: this.filteredCards(), + synthesis: this.synthesis(), + suggestedPrompt, + mode: this.experienceMode(), + }); + this.closeResults(); + void this.router.navigate(['/security/triage'], { + queryParams: { openChat: 'true', q: query || pageLabel }, + }); + } + + private resolveSuggestionReason(suggestion: { + reasonKey?: string; + reasonFallback?: string; + kind?: 'page' | 'recent' | 'strategy'; + }): string { + if (suggestion.reasonKey) { + return this.i18n.tryT(suggestion.reasonKey) + ?? suggestion.reasonFallback + ?? this.defaultSuggestionReason(suggestion.kind); + } + + return suggestion.reasonFallback ?? this.defaultSuggestionReason(suggestion.kind); + } + + private defaultSuggestionReason(kind: 'page' | 'recent' | 'strategy' | undefined): string { + switch (kind) { + case 'recent': + return 'Based on your last actions on this page.'; + case 'strategy': + return 'Generated from the recent intent on this page.'; + default: + return 'Useful starting points for the current page.'; + } + } + private recordAmbientAction( action: string, options: { @@ -1453,27 +2076,30 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { } private buildAskAiPromptForCard(card: EntityCard): string { + const directive = this.searchExperienceMode.definition().assistantDirective; + switch (card.domain) { case 'findings': - return `Tell me about ${card.title}, why it matters, and what action I should take first.`; + return `${directive} Focus on ${card.title}, why it matters, and the best next step.`; case 'vex': - return `Explain this VEX assessment for ${card.title} and what it means for release decisions.`; + return `${directive} Explain this VEX assessment for ${card.title} and the release implications.`; case 'policy': - return `Explain this policy rule (${card.title}) and how it affects promotions.`; + return `${directive} Explain policy rule ${card.title} and what it changes operationally.`; case 'platform': - return `Explain this platform item (${card.title}) and what an operator should do next.`; + return `${directive} Explain platform item ${card.title} and what an operator should do next.`; default: - return `Summarize ${card.title} and guide me through the next steps.`; + return `${directive} Summarize ${card.title} and guide me through the next steps.`; } } private buildAskAiPromptForSynthesis(): string { const query = this.query().trim(); + const directive = this.searchExperienceMode.definition().assistantDirective; if (!query) { - return 'I need help understanding these search results and what to do next.'; + return `${directive} I need help understanding these search results and what to do next.`; } - return `I searched for "${query}". Help me understand the results and recommend a clear next action.`; + return `I searched for "${query}". ${directive}`; } private normalizeActionRoute(route: string): string { diff --git a/src/Web/StellaOps.Web/src/tests/advisory_ai_chat/chat-message.component.spec.ts b/src/Web/StellaOps.Web/src/tests/advisory_ai_chat/chat-message.component.spec.ts index 21505a3e6..29f65390f 100644 --- a/src/Web/StellaOps.Web/src/tests/advisory_ai_chat/chat-message.component.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/advisory_ai_chat/chat-message.component.spec.ts @@ -1,9 +1,10 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideRouter } from '@angular/router'; +import { provideRouter, Router } from '@angular/router'; import { ChatMessageComponent } from '../../app/features/advisory-ai/chat/chat-message.component'; import { ConversationTurn } from '../../app/features/advisory-ai/chat/chat.models'; import { SearchChatContextService } from '../../app/core/services/search-chat-context.service'; +import { SearchExperienceModeService } from '../../app/core/services/search-experience-mode.service'; const assistantTurn: ConversationTurn = { turnId: 'turn-2', @@ -25,8 +26,12 @@ describe('ChatMessageComponent (advisory_ai_chat)', () => { let fixture: ComponentFixture; let component: ChatMessageComponent; let searchChatContext: SearchChatContextService; + let searchExperienceMode: SearchExperienceModeService; + let router: Router; beforeEach(async () => { + localStorage.clear(); + await TestBed.configureTestingModule({ imports: [ChatMessageComponent], providers: [provideRouter([])], @@ -35,6 +40,8 @@ describe('ChatMessageComponent (advisory_ai_chat)', () => { fixture = TestBed.createComponent(ChatMessageComponent); component = fixture.componentInstance; searchChatContext = TestBed.inject(SearchChatContextService); + searchExperienceMode = TestBed.inject(SearchExperienceModeService); + router = TestBed.inject(Router); component.turn = assistantTurn; fixture.detectChanges(); }); @@ -74,6 +81,7 @@ describe('ChatMessageComponent (advisory_ai_chat)', () => { action: 'chat_search_for_more', domain: 'findings', entityKey: 'api-gateway:grpc.Server', + mode: 'find', })); }); @@ -87,6 +95,42 @@ describe('ChatMessageComponent (advisory_ai_chat)', () => { domain: 'policy', entityKey: 'DENY-CRITICAL-PROD:1', action: 'chat_search_related', + mode: 'find', }); }); + + it('renders structured next-step cards for assistant messages with citations', () => { + const cards = fixture.nativeElement.querySelectorAll('.next-step-card'); + const modeBadge = fixture.nativeElement.querySelector('.next-steps__mode') as HTMLElement | null; + + expect(cards.length).toBe(4); + expect(modeBadge?.textContent?.trim()).toBe('Find mode'); + }); + + it('uses the active mode when a next-step search card hands control back to search', () => { + searchExperienceMode.setMode('act'); + fixture.detectChanges(); + const contextSpy = spyOn(searchChatContext, 'setChatToSearch'); + + const nextSearchCard = component.nextStepCards().find((card) => card.id === 'policy'); + expect(nextSearchCard).toBeDefined(); + + component.onNextStep(nextSearchCard!); + + expect(contextSpy).toHaveBeenCalledWith(jasmine.objectContaining({ + action: 'chat_next_step_policy', + domain: 'policy', + mode: 'act', + })); + }); + + it('navigates to evidence routes from next-step cards', () => { + const navigateSpy = spyOn(router, 'navigateByUrl').and.returnValue(Promise.resolve(true)); + const evidenceCard = component.nextStepCards().find((card) => card.id === 'evidence'); + + expect(evidenceCard?.route).toContain('/security/reachability'); + component.onNextStep(evidenceCard!); + + expect(navigateSpy).toHaveBeenCalledWith(evidenceCard!.route!); + }); }); diff --git a/src/Web/StellaOps.Web/src/tests/advisory_ai_chat/chat.component.spec.ts b/src/Web/StellaOps.Web/src/tests/advisory_ai_chat/chat.component.spec.ts new file mode 100644 index 000000000..93692d9e9 --- /dev/null +++ b/src/Web/StellaOps.Web/src/tests/advisory_ai_chat/chat.component.spec.ts @@ -0,0 +1,117 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { of, Subject } from 'rxjs'; + +import { AmbientContextService } from '../../app/core/services/ambient-context.service'; +import { SearchExperienceModeService } from '../../app/core/services/search-experience-mode.service'; +import { I18nService } from '../../app/core/i18n'; +import { ChatComponent } from '../../app/features/advisory-ai/chat/chat.component'; +import type { Conversation, StreamEvent } from '../../app/features/advisory-ai/chat/chat.models'; +import { ChatService } from '../../app/features/advisory-ai/chat/chat.service'; + +describe('ChatComponent (advisory_ai_chat)', () => { + let fixture: ComponentFixture; + let component: ChatComponent; + let searchExperienceMode: SearchExperienceModeService; + + const conversationState = signal(null); + const isLoadingState = signal(false); + const isStreamingState = signal(false); + const streamingContentState = signal(''); + const errorState = signal(null); + const streamEvents$ = new Subject(); + + const emptyConversation: Conversation = { + conversationId: 'conv-ux-1', + tenantId: 'default', + context: {}, + turns: [], + createdAt: '2026-03-06T10:00:00.000Z', + updatedAt: '2026-03-06T10:00:00.000Z', + }; + + const chatServiceStub = { + conversation: conversationState.asReadonly(), + isLoading: isLoadingState.asReadonly(), + isStreaming: isStreamingState.asReadonly(), + streamingContent: streamingContentState.asReadonly(), + error: errorState.asReadonly(), + streamEvents: streamEvents$.asObservable(), + createConversation: jasmine.createSpy('createConversation').and.callFake(() => { + conversationState.set(emptyConversation); + return of(emptyConversation); + }), + getConversation: jasmine.createSpy('getConversation').and.callFake(() => of(emptyConversation)), + sendMessage: jasmine.createSpy('sendMessage'), + clearConversation: jasmine.createSpy('clearConversation').and.callFake(() => { + conversationState.set(null); + }), + }; + + const ambientContextStub = { + getChatSuggestions: jasmine.createSpy('getChatSuggestions').and.returnValue([ + { key: 'ui.chat.suggestion.default.what_can_do', fallback: 'What can Stella Ops do?' }, + { key: 'ui.chat.suggestion.default.health_checks', fallback: 'What health checks should I run first?' }, + ]), + }; + + beforeEach(async () => { + localStorage.clear(); + conversationState.set(null); + isLoadingState.set(false); + isStreamingState.set(false); + streamingContentState.set(''); + errorState.set(null); + chatServiceStub.createConversation.calls.reset(); + chatServiceStub.getConversation.calls.reset(); + chatServiceStub.sendMessage.calls.reset(); + chatServiceStub.clearConversation.calls.reset(); + + await TestBed.configureTestingModule({ + imports: [ChatComponent], + providers: [ + { provide: ChatService, useValue: chatServiceStub }, + { provide: AmbientContextService, useValue: ambientContextStub }, + { + provide: I18nService, + useValue: { + tryT: () => null, + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ChatComponent); + component = fixture.componentInstance; + searchExperienceMode = TestBed.inject(SearchExperienceModeService); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + }); + + it('renders the mode switcher in the chat header', () => { + const buttons = fixture.nativeElement.querySelectorAll('.chat-mode-btn'); + + expect(buttons.length).toBe(3); + expect(buttons[0].textContent.trim()).toBe('Find'); + expect(buttons[1].textContent.trim()).toBe('Explain'); + expect(buttons[2].textContent.trim()).toBe('Act'); + }); + + it('updates starter suggestions and placeholder when the mode changes', () => { + const modeButtons = fixture.nativeElement.querySelectorAll('.chat-mode-btn'); + (modeButtons[1] as HTMLButtonElement).click(); + fixture.detectChanges(); + + const suggestionButtons = fixture.nativeElement.querySelectorAll('.suggestion-btn'); + const textarea = fixture.nativeElement.querySelector('.chat-input') as HTMLTextAreaElement | null; + const emptyState = fixture.nativeElement.querySelector('.empty-state p') as HTMLElement | null; + + expect(searchExperienceMode.currentMode()).toBe('explain'); + expect(emptyState?.textContent).toContain('Understand why it matters'); + expect(suggestionButtons[0].textContent.trim()).toBe( + 'Explain the evidence chain and policy impact behind the top issue.', + ); + expect(textarea?.placeholder).toContain('explain this issue'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/tests/context/ambient-context.service.spec.ts b/src/Web/StellaOps.Web/src/tests/context/ambient-context.service.spec.ts index 46fc52733..96b1d89f4 100644 --- a/src/Web/StellaOps.Web/src/tests/context/ambient-context.service.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/context/ambient-context.service.spec.ts @@ -68,6 +68,10 @@ describe('AmbientContextService', () => { expect(suggestions[0]).toEqual({ key: 'ui.search.suggestion.last_action.follow_up', fallback: 'follow up: CVE-2024-21626', + 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'); }); @@ -134,4 +138,33 @@ describe('AmbientContextService', () => { expect(ambient.lastAction?.domain).toBe('findings'); expect(ambient.lastAction?.entityKey).toBe('finding:fnd-2'); }); + + it('builds a context panel with page, scope, and last action tokens', () => { + const service = TestBed.inject(AmbientContextService); + service.recordAction({ + action: 'search_result_open', + queryHint: 'CVE-2024-21626', + domain: 'findings', + }); + + const panel = service.getSearchContextPanel(); + + expect(panel).not.toBeNull(); + expect(panel?.titleKey).toBe('ui.search.context.findings.title'); + expect(panel?.titleFallback).toBe('Findings triage'); + expect(panel?.tokens).toEqual(jasmine.arrayContaining([ + jasmine.objectContaining({ + key: 'page', + value: 'Findings triage', + }), + jasmine.objectContaining({ + key: 'scope', + value: 'Findings', + }), + jasmine.objectContaining({ + key: 'last-action', + value: 'Opened result for CVE-2024-21626', + }), + ])); + }); }); diff --git a/src/Web/StellaOps.Web/src/tests/context/search-experience-mode.service.spec.ts b/src/Web/StellaOps.Web/src/tests/context/search-experience-mode.service.spec.ts new file mode 100644 index 000000000..2e726d03c --- /dev/null +++ b/src/Web/StellaOps.Web/src/tests/context/search-experience-mode.service.spec.ts @@ -0,0 +1,49 @@ +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]); + }); +}); diff --git a/src/Web/StellaOps.Web/src/tests/global_search/global-search.component.spec.ts b/src/Web/StellaOps.Web/src/tests/global_search/global-search.component.spec.ts index 622f399dc..24d11a82a 100644 --- a/src/Web/StellaOps.Web/src/tests/global_search/global-search.component.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/global_search/global-search.component.spec.ts @@ -6,6 +6,7 @@ import type { EntityCard } from '../../app/core/api/unified-search.models'; import { UnifiedSearchClient } from '../../app/core/api/unified-search.client'; import { AmbientContextService } from '../../app/core/services/ambient-context.service'; import { SearchChatContextService } from '../../app/core/services/search-chat-context.service'; +import { SearchExperienceModeService } from '../../app/core/services/search-experience-mode.service'; import { I18nService } from '../../app/core/i18n'; import { GlobalSearchComponent } from '../../app/layout/global-search/global-search.component'; @@ -17,8 +18,10 @@ describe('GlobalSearchComponent', () => { let routerEvents: Subject; let router: { url: string; events: Subject; navigateByUrl: jasmine.Spy; navigate: jasmine.Spy }; let searchChatContext: jasmine.SpyObj; + let searchExperienceMode: SearchExperienceModeService; beforeEach(async () => { + localStorage.clear(); routerEvents = new Subject(); router = { url: '/security/triage', @@ -52,15 +55,51 @@ describe('GlobalSearchComponent', () => { ambientContext = jasmine.createSpyObj('AmbientContextService', [ 'buildContextFilter', + 'getSearchContextPanel', 'getSearchSuggestions', 'buildAmbientContext', 'recordAction', ]) as jasmine.SpyObj; ambientContext.buildContextFilter.and.returnValue(undefined); + ambientContext.getSearchContextPanel.and.returnValue({ + titleKey: 'ui.search.context.findings.title', + titleFallback: 'Findings triage', + descriptionKey: 'ui.search.context.findings.description', + descriptionFallback: 'Investigate live findings, reachability, and remediation evidence.', + tokens: [ + { + key: 'page', + labelKey: 'ui.search.context.token.page', + labelFallback: 'Page', + value: 'Findings triage', + }, + { + key: 'scope', + labelKey: 'ui.search.context.token.scope', + labelFallback: 'Scope', + value: 'Findings', + }, + ], + }); ambientContext.getSearchSuggestions.and.returnValue([ - { 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' }, + { + key: 'ui.search.suggestion.default.deploy', + fallback: 'How do I deploy?', + reasonFallback: 'Useful starting points across Stella Ops.', + kind: 'page', + }, + { + key: 'ui.search.suggestion.default.vex', + fallback: 'What is a VEX statement?', + reasonFallback: 'Useful starting points across Stella Ops.', + kind: 'page', + }, + { + key: 'ui.search.suggestion.default.critical', + fallback: 'Show critical findings', + reasonFallback: 'Useful starting points across Stella Ops.', + kind: 'page', + }, ]); ambientContext.buildAmbientContext.and.returnValue({ currentRoute: '/security/triage', @@ -93,6 +132,7 @@ describe('GlobalSearchComponent', () => { fixture = TestBed.createComponent(GlobalSearchComponent); component = fixture.componentInstance; + searchExperienceMode = TestBed.inject(SearchExperienceModeService); fixture.detectChanges(); }); @@ -120,6 +160,22 @@ describe('GlobalSearchComponent', () => { expect(cards.length).toBe(8); }); + it('renders the context rail and suggestion rationale in empty state', () => { + component.onFocus(); + fixture.detectChanges(); + + const contextTitle = fixture.nativeElement.querySelector('.search__context-title') as HTMLElement | null; + const contextTokens = Array.from( + fixture.nativeElement.querySelectorAll('.search__context-token') as NodeListOf, + ).map((node) => node.textContent?.replace(/\s+/g, ' ').trim()); + const suggestionReason = fixture.nativeElement.querySelector('.search__suggestion-reason') as HTMLElement | null; + + expect(contextTitle?.textContent?.trim()).toBe('Findings triage'); + expect(contextTokens).toContain('Page: Findings triage'); + expect(contextTokens).toContain('Scope: Findings'); + expect(suggestionReason?.textContent?.trim()).toBe('Useful starting points across Stella Ops.'); + }); + it('queries unified search for one-character query terms', async () => { component.onFocus(); component.onQueryChange('a'); @@ -197,10 +253,13 @@ describe('GlobalSearchComponent', () => { it('navigates to assistant host with openChat intent from Ask AI card action', () => { const card = createCard('findings', '/triage/findings/fnd-1'); + searchExperienceMode.setMode('act'); component.onAskAiFromCard(card); - expect(searchChatContext.setSearchToChat).toHaveBeenCalled(); + expect(searchChatContext.setSearchToChat).toHaveBeenCalledWith(jasmine.objectContaining({ + mode: 'act', + })); expect(router.navigate).toHaveBeenCalledWith( ['/security/triage'], jasmine.objectContaining({ @@ -305,6 +364,98 @@ describe('GlobalSearchComponent', () => { expect(component.isFocused()).toBeTrue(); }); + it('keeps the search panel open when focus moves into experience controls', async () => { + component.onFocus(); + component.onQueryChange('critical findings'); + await waitForDebounce(); + fixture.detectChanges(); + + const explainButton = fixture.nativeElement.querySelectorAll('.search__segment')[1] as HTMLButtonElement | undefined; + expect(explainButton).toBeDefined(); + + explainButton!.focus(); + component.onBlur(); + await new Promise((resolve) => setTimeout(resolve, 250)); + + expect(component.isFocused()).toBeTrue(); + }); + + it('renders rescue actions when a query returns no results', async () => { + component.onFocus(); + component.onQueryChange('no results'); + await waitForDebounce(); + fixture.detectChanges(); + + const rescueCards = fixture.nativeElement.querySelectorAll('.search__rescue-card'); + expect(rescueCards.length).toBe(4); + }); + + it('retries the active query globally when scope rescue toggles off page filtering', async () => { + ambientContext.buildContextFilter.and.returnValue({ domains: ['findings'] } as any); + + component.onFocus(); + component.onQueryChange('critical findings'); + await waitForDebounce(); + + const scopedCall = searchClient.search.calls.mostRecent(); + expect(scopedCall).toBeDefined(); + expect(scopedCall!.args[1]).toEqual({ domains: ['findings'] }); + + searchClient.search.calls.reset(); + component.runRescueAction('scope'); + await waitForDebounce(); + + const globalCall = searchClient.search.calls.mostRecent(); + expect(globalCall).toBeDefined(); + expect(globalCall!.args[1]).toBeUndefined(); + expect(ambientContext.recordAction).toHaveBeenCalledWith(jasmine.objectContaining({ + action: 'search_rescue_scope', + })); + }); + + it('opens AdvisoryAI reformulation with the current mode and query context', () => { + searchExperienceMode.setMode('explain'); + component.onFocus(); + component.query.set('mismatch'); + + component.runRescueAction('reformulate'); + + expect(searchChatContext.setSearchToChat).toHaveBeenCalledWith(jasmine.objectContaining({ + query: 'mismatch', + mode: 'explain', + suggestedPrompt: jasmine.stringMatching(/Reformulate the search query "mismatch"/), + })); + expect(router.navigate).toHaveBeenCalledWith( + ['/security/triage'], + jasmine.objectContaining({ + queryParams: jasmine.objectContaining({ + openChat: 'true', + q: 'mismatch', + }), + }), + ); + }); + + it('drops the route filter when search scope is toggled to global', async () => { + ambientContext.buildContextFilter.and.returnValue({ domains: ['findings'] } as any); + + component.onFocus(); + component.onQueryChange('CVE-2024-21626'); + await waitForDebounce(); + const pageScopedCall = searchClient.search.calls.mostRecent(); + expect(pageScopedCall).toBeDefined(); + expect(pageScopedCall!.args[1]).toEqual({ domains: ['findings'] }); + + searchClient.search.calls.reset(); + component.toggleSearchScope(); + await waitForDebounce(); + + expect(component.searchScope()).toBe('global'); + const unscopedCall = searchClient.search.calls.mostRecent(); + expect(unscopedCall).toBeDefined(); + expect(unscopedCall!.args[1]).toBeUndefined(); + }); + function createCard(domain: EntityCard['domain'], route: string): EntityCard { return { entityKey: `${domain}:sample`, diff --git a/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts b/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts index a2ca2da23..712d191b7 100644 --- a/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts +++ b/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts @@ -58,21 +58,34 @@ test.describe('Unified Search - Contextual Suggestions', () => { await setupAuthenticatedSession(page); }); - test('updates empty-state chips automatically when route changes', async ({ page }) => { + test('updates context rail and empty-state chips automatically when route changes', async ({ page }) => { await page.goto('/security/triage'); await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 }); const searchInput = page.locator('app-global-search input[type="text"]'); await searchInput.focus(); await expect(page.locator('.search__results')).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('.search__context-title')).toContainText(/findings triage/i); + await expect(page.locator('.search__context-token', { + hasText: /scope:\s+findings/i, + }).first()).toBeVisible(); await expect(page.locator('.search__suggestions .search__chip', { hasText: /critical findings/i, }).first()).toBeVisible(); + await expect( + page.locator('.search__suggestion-card', { + has: page.locator('.search__chip', { hasText: /^critical findings$/i }), + }).locator('.search__suggestion-reason'), + ).toContainText(/triage pivots/i); await page.goto('/ops/policy'); await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 }); await searchInput.focus(); await expect(page.locator('.search__results')).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('.search__context-title')).toContainText(/policy workspace/i); + await expect(page.locator('.search__context-token', { + hasText: /scope:\s+policy rules/i, + }).first()).toBeVisible(); await expect(page.locator('.search__suggestions .search__chip', { hasText: /failing policy gates/i, }).first()).toBeVisible(); @@ -81,6 +94,7 @@ test.describe('Unified Search - Contextual Suggestions', () => { await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 }); await searchInput.focus(); await expect(page.locator('.search__results')).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('.search__context-title')).toContainText(/timeline analysis/i); await expect(page.locator('.search__suggestions .search__chip', { hasText: /failed deployments/i, }).first()).toBeVisible(); @@ -114,9 +128,15 @@ test.describe('Unified Search - Contextual Suggestions', () => { await searchInput.focus(); await expect(page.locator('.search__results')).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('.search__context-token', { + hasText: /last action:\s+opened result for cve-2024-21626/i, + }).first()).toBeVisible(); await expect(page.locator('.search__suggestions .search__chip', { hasText: /follow up:\s*CVE-2024-21626/i, }).first()).toBeVisible(); + await expect(page.locator('.search__suggestion-card--recent .search__suggestion-reason').first()).toContainText( + /last actions on this page/i, + ); }); test('chat search-for-more emits ambient lastAction and route context in follow-up search requests', async ({ diff --git a/src/Web/StellaOps.Web/tests/e2e/unified-search-experience-quality.e2e.spec.ts b/src/Web/StellaOps.Web/tests/e2e/unified-search-experience-quality.e2e.spec.ts new file mode 100644 index 000000000..93db69530 --- /dev/null +++ b/src/Web/StellaOps.Web/tests/e2e/unified-search-experience-quality.e2e.spec.ts @@ -0,0 +1,320 @@ +import { expect, test, type Page } from '@playwright/test'; + +import { + buildResponse, + emptyResponse, + findingCard, + policyCard, + setupAuthenticatedSession, + setupBasicMocks, + typeInSearch, + waitForEntityCards, + waitForResults, +} from './unified-search-fixtures'; + +const criticalFindingResponse = buildResponse( + 'critical findings', + [ + findingCard({ + cveId: 'CVE-2024-21626', + title: 'CVE-2024-21626 in api-gateway', + snippet: 'Reachable critical vulnerability detected in production workload.', + severity: 'critical', + }), + ], + { + summary: 'One critical finding matched. Ask AdvisoryAI for triage guidance.', + template: 'finding_overview', + confidence: 'high', + sourceCount: 1, + domainsCovered: ['findings'], + }, +); + +const broadenedScopeResponse = buildResponse( + 'scope sensitive outage', + [ + policyCard({ + ruleId: 'DENY-CRITICAL-PROD', + title: 'DENY-CRITICAL-PROD', + snippet: 'Production deny rule linked to the active incident.', + }), + ], + { + summary: 'The broader search found a policy blocker outside the page scope.', + template: 'policy_overview', + confidence: 'high', + sourceCount: 1, + domainsCovered: ['policy'], + }, +); + +const policyBlockerResponse = buildResponse( + 'policy blockers for CVE-2024-21626', + [ + policyCard({ + ruleId: 'POL-118', + title: 'POL-118 release blocker', + snippet: 'Production rollout is blocked while this CVE remains unresolved.', + }), + ], + { + summary: 'Policy blockers were found for this CVE.', + template: 'policy_overview', + confidence: 'high', + sourceCount: 1, + domainsCovered: ['policy'], + }, +); + +test.describe('Unified Search - Experience Quality UX', () => { + test.beforeEach(async ({ page }) => { + await setupBasicMocks(page); + await setupAuthenticatedSession(page); + }); + + test('keeps keyboard-selected mode when handing off from search to AdvisoryAI', async ({ page }) => { + await mockSearchResponses(page, (query) => { + if (query.includes('critical findings')) { + return criticalFindingResponse; + } + + return emptyResponse(query); + }); + await mockChatConversation(page, { + content: 'AdvisoryAI is ready to explain the finding and cite evidence.', + citations: [{ type: 'finding', path: 'CVE-2024-21626', verified: true }], + groundingScore: 0.94, + }); + + await page.goto('/security/triage'); + await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 }); + + await typeInSearch(page, 'critical findings'); + await waitForResults(page); + await waitForEntityCards(page, 1); + + await page.locator('app-global-search input[type="text"]').focus(); + await waitForResults(page); + + const explainButton = page.locator('.search__experience-bar').getByRole('button', { name: 'Explain' }); + await explainButton.focus(); + await explainButton.press('Enter'); + await expect(explainButton).toHaveClass(/search__segment--active/); + + await page.locator('app-global-search input[type="text"]').focus(); + await waitForResults(page); + await page.locator('.entity-card__action--ask-ai').first().click(); + + await expect(page.locator('.assistant-drawer')).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('.chat-mode-btn--active')).toHaveText(/Explain/i); + }); + + test('broadens zero-result searches to all domains and reruns the same query', async ({ page }) => { + const capturedRequests: Array> = []; + await page.route('**/search/query**', async (route) => { + const request = route.request().postDataJSON() as Record; + capturedRequests.push(request); + + const query = String(request['q'] ?? '').toLowerCase(); + const filters = request['filters'] as Record | undefined; + const hasPageScope = Array.isArray(filters?.['domains']) && filters!['domains'].length > 0; + + const response = query.includes('scope sensitive outage') + ? hasPageScope + ? emptyResponse('scope sensitive outage') + : broadenedScopeResponse + : emptyResponse(query); + + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response), + }); + }); + + await page.goto('/security/triage'); + await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 }); + + await typeInSearch(page, 'scope sensitive outage'); + await waitForResults(page); + await expect(page.locator('.search__empty')).toContainText(/no results found/i); + await expect(page.locator('.search__rescue-card')).toHaveCount(4); + + await page.locator('[data-rescue-action="scope"]').click(); + await page.locator('app-global-search input[type="text"]').focus(); + await waitForResults(page); + await expect(page.locator('[data-role="search-scope"]')).toContainText(/All domains/i); + await expect(page.locator('app-global-search input[type="text"]')).toHaveValue('scope sensitive outage'); + await expect(page.locator('.search__cards')).toContainText(/DENY-CRITICAL-PROD/i); + + expect(capturedRequests[0]?.['q']).toBe('scope sensitive outage'); + }); + + test('opens AdvisoryAI reformulation from the zero-result rescue flow', async ({ page }) => { + await mockSearchResponses(page, (query) => + query.includes('mystery remediation') ? emptyResponse('mystery remediation') : emptyResponse(query)); + await mockChatConversation(page, { + content: 'I can reformulate that query for better recall.', + citations: [{ type: 'docs', path: 'modules/ui/search-chip-context-contract.md', verified: true }], + groundingScore: 0.91, + }); + + await page.goto('/security/triage'); + await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 }); + + await page.locator('app-global-search input[type="text"]').focus(); + await waitForResults(page); + await page.locator('.search__experience-bar').getByRole('button', { name: 'Explain' }).click(); + await typeInSearch(page, 'mystery remediation'); + await waitForResults(page); + await expect(page.locator('.search__empty')).toContainText(/no results found/i); + + await page.locator('[data-rescue-action="reformulate"]').click(); + + await expect(page.locator('.assistant-drawer')).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('.chat-mode-btn--active')).toHaveText(/Explain/i); + await expect(page.locator('.chat-message.user .message-body').first()).toContainText( + /Reformulate the search query "mystery remediation"/i, + ); + }); + + test('returns structured next-step policy searches back into global search with metadata', async ({ page }) => { + const capturedRequests: Array> = []; + await page.route('**/search/query**', async (route) => { + const request = route.request().postDataJSON() as Record; + capturedRequests.push(request); + + const query = String(request['q'] ?? '').toLowerCase(); + const response = query.includes('policy blockers for cve-2024-21626') + ? policyBlockerResponse + : criticalFindingResponse; + + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response), + }); + }); + await mockChatConversation(page, { + content: 'CVE-2024-21626 is still gating release decisions and policy evidence should be checked.', + citations: [ + { type: 'finding', path: 'CVE-2024-21626', verified: true }, + { type: 'policy', path: 'POL-118', verified: true }, + ], + groundingScore: 0.96, + }); + + await page.goto('/security/triage'); + await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 }); + + await typeInSearch(page, 'critical findings'); + await waitForResults(page); + await waitForEntityCards(page, 1); + await page.locator('app-global-search input[type="text"]').focus(); + await waitForResults(page); + await page.locator('.search__experience-bar').getByRole('button', { name: 'Act' }).click(); + await page.locator('app-global-search input[type="text"]').focus(); + await waitForResults(page); + await page.locator('.entity-card__action--ask-ai').first().click(); + + await expect(page.locator('.assistant-drawer')).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('[data-next-step="policy"]')).toBeVisible({ timeout: 10_000 }); + + await page.locator('[data-next-step="policy"]').click(); + + await expect(page.locator('.assistant-drawer')).toBeHidden({ timeout: 10_000 }); + await waitForResults(page); + await waitForEntityCards(page, 1); + await expect(page.locator('app-global-search input[type="text"]')).toHaveValue(/policy blockers for CVE-2024-21626/i); + + const policyRequest = capturedRequests.find((request) => + String(request['q'] ?? '').toLowerCase().includes('policy blockers for cve-2024-21626')); + const ambient = policyRequest?.['ambient'] as Record | undefined; + const lastAction = ambient?.['lastAction'] as Record | undefined; + + expect(lastAction?.['action']).toBe('chat_next_step_policy'); + expect(lastAction?.['source']).toBe('advisory_ai_chat'); + expect(lastAction?.['domain']).toBe('policy'); + }); +}); + +async function mockSearchResponses( + page: Page, + resolve: (normalizedQuery: string) => unknown, +): Promise { + await page.route('**/search/query**', async (route) => { + const body = route.request().postDataJSON() as Record; + const query = String(body['q'] ?? '').toLowerCase(); + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(resolve(query)), + }); + }); +} + +async function mockChatConversation( + page: Page, + response: { + content: string; + citations: Array<{ type: string; path: string; verified: boolean }>; + groundingScore: number; + }, +): Promise { + await page.route('**/api/v1/advisory-ai/conversations', async (route) => { + if (route.request().method() !== 'POST') { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([]), + }); + } + + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + conversationId: 'conv-quality-1', + tenantId: 'test-tenant', + userId: 'tester', + context: {}, + turns: [], + createdAt: '2026-03-06T00:00:00.000Z', + updatedAt: '2026-03-06T00:00:00.000Z', + }), + }); + }); + + await page.route('**/api/v1/advisory-ai/conversations/*/turns', async (route) => { + if (route.request().method() !== 'POST') { + return route.continue(); + } + + const events = [ + 'event: progress', + 'data: {"stage":"searching"}', + '', + 'event: token', + `data: ${JSON.stringify({ content: response.content })}`, + '', + ...response.citations.flatMap((citation) => ([ + 'event: citation', + `data: ${JSON.stringify(citation)}`, + '', + ])), + 'event: done', + `data: ${JSON.stringify({ turnId: 'turn-quality-1', groundingScore: response.groundingScore })}`, + '', + ].join('\n'); + + return route.fulfill({ + status: 200, + headers: { + 'content-type': 'text/event-stream; charset=utf-8', + 'cache-control': 'no-cache', + }, + body: events, + }); + }); +}