Improve search and advisory UX flows
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 '/';
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SearchExperienceMode>(this.resolveInitialMode());
|
||||
|
||||
readonly mode = this.selectedMode.asReadonly();
|
||||
readonly definition = computed<SearchExperienceModeDefinition>(() =>
|
||||
this.findDefinition(this.selectedMode()),
|
||||
);
|
||||
|
||||
setMode(mode: SearchExperienceMode): void {
|
||||
if (!this.isSupportedMode(mode) || this.selectedMode() === mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedMode.set(mode);
|
||||
this.persistMode(mode);
|
||||
}
|
||||
|
||||
currentMode(): SearchExperienceMode {
|
||||
return this.selectedMode();
|
||||
}
|
||||
|
||||
getDefinition(mode: SearchExperienceMode): SearchExperienceModeDefinition {
|
||||
return this.findDefinition(mode);
|
||||
}
|
||||
|
||||
private findDefinition(mode: SearchExperienceMode): SearchExperienceModeDefinition {
|
||||
return this.definitions.find((definition) => definition.id === mode) ?? this.definitions[0];
|
||||
}
|
||||
|
||||
private resolveInitialMode(): SearchExperienceMode {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'find';
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(this.storageKey);
|
||||
if (stored && this.isSupportedMode(stored)) {
|
||||
return stored;
|
||||
}
|
||||
} catch {
|
||||
return 'find';
|
||||
}
|
||||
|
||||
return 'find';
|
||||
}
|
||||
|
||||
private persistMode(mode: SearchExperienceMode): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(this.storageKey, mode);
|
||||
} catch {
|
||||
// Ignore localStorage failures and keep the in-memory selection.
|
||||
}
|
||||
}
|
||||
|
||||
private isSupportedMode(value: string): value is SearchExperienceMode {
|
||||
return this.definitions.some((definition) => definition.id === value);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (nextStepCards().length > 0) {
|
||||
<section class="next-steps" aria-label="Suggested next steps">
|
||||
<div class="next-steps__header">
|
||||
<span class="next-steps__title">Suggested next steps</span>
|
||||
<span class="next-steps__mode">{{ currentModeLabel() }}</span>
|
||||
</div>
|
||||
<div class="next-steps__grid">
|
||||
@for (card of nextStepCards(); track card.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="next-step-card"
|
||||
[attr.data-next-step]="card.id"
|
||||
(click)="onNextStep(card)"
|
||||
>
|
||||
<span class="next-step-card__eyebrow">{{ card.evidenceLabel }}</span>
|
||||
<span class="next-step-card__title">{{ card.title }}</span>
|
||||
<span class="next-step-card__description">{{ card.description }}</span>
|
||||
<span class="next-step-card__cta">{{ card.actionLabel }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Proposed actions -->
|
||||
@if (turn.proposedActions && turn.proposedActions.length > 0) {
|
||||
<div class="message-actions">
|
||||
@@ -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<ProposedAction>();
|
||||
@Output() searchForMore = new EventEmitter<string>();
|
||||
|
||||
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';
|
||||
|
||||
@@ -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 {
|
||||
<!-- Header -->
|
||||
<header class="chat-header">
|
||||
<div class="header-left">
|
||||
<svg class="header-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2a10 10 0 1 0 10 10 4 4 0 0 1-5-5 4 4 0 0 1-5-5"/>
|
||||
<path d="M8.5 8.5v.01"/>
|
||||
<path d="M16 15.5v.01"/>
|
||||
<path d="M12 12v.01"/>
|
||||
</svg>
|
||||
<h2 class="header-title">AdvisoryAI</h2>
|
||||
@if (conversation()) {
|
||||
<span class="conversation-id">{{ conversation()!.conversationId.substring(0, 8) }}</span>
|
||||
}
|
||||
<div class="header-brand">
|
||||
<svg class="header-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2a10 10 0 1 0 10 10 4 4 0 0 1-5-5 4 4 0 0 1-5-5"/>
|
||||
<path d="M8.5 8.5v.01"/>
|
||||
<path d="M16 15.5v.01"/>
|
||||
<path d="M12 12v.01"/>
|
||||
</svg>
|
||||
<h2 class="header-title">AdvisoryAI</h2>
|
||||
@if (conversation()) {
|
||||
<span class="conversation-id">{{ conversation()!.conversationId.substring(0, 8) }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="chat-mode-switcher">
|
||||
@for (mode of experienceModeOptions(); track mode.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="chat-mode-btn"
|
||||
data-role="chat-mode"
|
||||
[class.chat-mode-btn--active]="experienceMode() === mode.id"
|
||||
[title]="mode.description"
|
||||
(click)="setMode(mode.id)"
|
||||
>
|
||||
{{ mode.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
@if (conversation()) {
|
||||
@@ -109,7 +129,7 @@ import {
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
<h3>Ask AdvisoryAI</h3>
|
||||
<p>Ask questions about vulnerabilities, exploitability, remediation, or integrations.</p>
|
||||
<p>{{ modeEmptyStateDescription() }}</p>
|
||||
<div class="suggestions">
|
||||
@for (suggestion of suggestions(); track suggestion) {
|
||||
<button
|
||||
@@ -224,6 +244,13 @@ import {
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -251,6 +278,38 @@ import {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.chat-mode-switcher {
|
||||
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);
|
||||
}
|
||||
|
||||
.chat-mode-btn {
|
||||
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;
|
||||
}
|
||||
|
||||
.chat-mode-btn:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-nav-hover);
|
||||
}
|
||||
|
||||
.chat-mode-btn--active {
|
||||
background: var(--color-brand-primary-10, #eff6ff);
|
||||
color: var(--color-brand-primary, #1d4ed8);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -554,6 +613,7 @@ export class ChatComponent implements OnInit, OnDestroy {
|
||||
|
||||
private readonly chatService = inject(ChatService);
|
||||
private readonly ambientContext = inject(AmbientContextService);
|
||||
private readonly searchExperienceMode = inject(SearchExperienceModeService);
|
||||
private readonly i18n = inject(I18nService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
private pendingInitialMessage: string | null = null;
|
||||
@@ -576,13 +636,35 @@ export class ChatComponent implements OnInit, OnDestroy {
|
||||
if (this.isStreaming()) {
|
||||
return this.i18n.tryT('ui.chat.input.waiting') ?? 'Waiting for response...';
|
||||
}
|
||||
return this.i18n.tryT('ui.chat.input.placeholder') ?? 'Ask AdvisoryAI about this finding...';
|
||||
|
||||
switch (this.experienceMode()) {
|
||||
case 'explain':
|
||||
return this.i18n.tryT('ui.chat.input.placeholder.explain') ?? 'Ask AdvisoryAI to explain this issue...';
|
||||
case 'act':
|
||||
return this.i18n.tryT('ui.chat.input.placeholder.act') ?? 'Ask AdvisoryAI what to do next...';
|
||||
default:
|
||||
return this.i18n.tryT('ui.chat.input.placeholder') ?? 'Ask AdvisoryAI about this finding...';
|
||||
}
|
||||
});
|
||||
|
||||
readonly suggestions = computed(() =>
|
||||
this.ambientContext
|
||||
readonly experienceMode = this.searchExperienceMode.mode;
|
||||
readonly experienceModeOptions = computed(() =>
|
||||
this.searchExperienceMode.definitions.map((mode) => ({
|
||||
id: mode.id,
|
||||
label: this.i18n.tryT(mode.labelKey) ?? mode.labelFallback,
|
||||
description: this.i18n.tryT(mode.descriptionKey) ?? mode.descriptionFallback,
|
||||
})));
|
||||
readonly modeEmptyStateDescription = computed(() =>
|
||||
this.i18n.tryT(this.searchExperienceMode.definition().descriptionKey)
|
||||
?? this.searchExperienceMode.definition().descriptionFallback);
|
||||
readonly suggestions = computed(() => {
|
||||
const starter = this.searchExperienceMode.definition().chatStarter;
|
||||
const ambient = this.ambientContext
|
||||
.getChatSuggestions()
|
||||
.map((suggestion) => this.i18n.tryT(suggestion.key) ?? suggestion.fallback));
|
||||
.map((suggestion) => this.i18n.tryT(suggestion.key) ?? suggestion.fallback);
|
||||
|
||||
return [starter, ...ambient.filter((suggestion) => suggestion !== starter)].slice(0, 5);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Auto-scroll on new content
|
||||
@@ -642,6 +724,10 @@ export class ChatComponent implements OnInit, OnDestroy {
|
||||
this.sendMessage();
|
||||
}
|
||||
|
||||
setMode(mode: SearchExperienceMode): void {
|
||||
this.searchExperienceMode.setMode(mode);
|
||||
}
|
||||
|
||||
handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||
import { SearchChatContextService, type SearchToChatContext } from '../../core/services/search-chat-context.service';
|
||||
import { SearchExperienceModeService } from '../../core/services/search-experience-mode.service';
|
||||
import { ChatComponent } from '../advisory-ai/chat';
|
||||
import { SecurityFindingsPageComponent } from './security-findings-page.component';
|
||||
|
||||
@@ -105,6 +106,7 @@ export class SecurityTriageChatHostComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly searchChatContext = inject(SearchChatContextService);
|
||||
private readonly searchExperienceMode = inject(SearchExperienceModeService);
|
||||
readonly context = inject(PlatformContextStore);
|
||||
|
||||
@ViewChild('assistantDrawer') private assistantDrawerRef?: ElementRef<HTMLElement>;
|
||||
@@ -124,8 +126,9 @@ export class SecurityTriageChatHostComponent {
|
||||
}
|
||||
|
||||
openAssistantPanel(): void {
|
||||
const directive = this.searchExperienceMode.definition().assistantDirective;
|
||||
this.assistantInitialMessage.set(
|
||||
'Help me prioritize the current security triage findings and explain the top risk first.',
|
||||
`${directive} Help me prioritize the current security triage findings and explain the top risk first.`,
|
||||
);
|
||||
this.assistantOpen.set(true);
|
||||
this.queueDrawerFocus();
|
||||
@@ -165,6 +168,8 @@ export class SecurityTriageChatHostComponent {
|
||||
return searchContext.suggestedPrompt.trim();
|
||||
}
|
||||
|
||||
const mode = searchContext?.mode ?? this.searchExperienceMode.currentMode();
|
||||
const directive = this.searchExperienceMode.getDefinition(mode).assistantDirective;
|
||||
const query = searchContext?.query?.trim() || querySeed.trim();
|
||||
const cards = (searchContext?.entityCards ?? []).slice(0, 5);
|
||||
if (cards.length > 0) {
|
||||
@@ -172,14 +177,14 @@ export class SecurityTriageChatHostComponent {
|
||||
.map((card, index) =>
|
||||
`${index + 1}. ${card.title} (${card.domain}${card.severity ? `, ${card.severity}` : ''})`)
|
||||
.join('\n');
|
||||
return `I searched for "${query || 'security issue'}" and got:\n${cardSummary}\nHelp me understand the risk and choose the best next action.`;
|
||||
return `I searched for "${query || 'security issue'}" and got:\n${cardSummary}\n${directive}`;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
return `Help me understand "${query}" and guide me to the most relevant next step.`;
|
||||
return `${directive} Focus on "${query}" and guide me to the most relevant next step.`;
|
||||
}
|
||||
|
||||
return 'Help me prioritize the current security triage findings and explain the top risk first.';
|
||||
return `${directive} Help me prioritize the current security triage findings and explain the top risk first.`;
|
||||
}
|
||||
|
||||
@HostListener('window:keydown.escape')
|
||||
|
||||
@@ -15,7 +15,6 @@ import { Subject, of } from 'rxjs';
|
||||
import {
|
||||
catchError,
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
@@ -34,10 +33,35 @@ import { EntityCardComponent } from '../../shared/components/entity-card/entity-
|
||||
import { SynthesisPanelComponent } from '../../shared/components/synthesis-panel/synthesis-panel.component';
|
||||
import { AmbientContextService } from '../../core/services/ambient-context.service';
|
||||
import { SearchChatContextService } from '../../core/services/search-chat-context.service';
|
||||
import {
|
||||
SearchExperienceModeService,
|
||||
type SearchExperienceMode,
|
||||
} from '../../core/services/search-experience-mode.service';
|
||||
import { I18nService } from '../../core/i18n';
|
||||
import { normalizeSearchActionRoute } from './search-route-matrix';
|
||||
|
||||
type SearchDomainFilter = 'all' | UnifiedSearchDomain;
|
||||
type SearchScopeMode = 'page' | 'global';
|
||||
type SearchSuggestionView = {
|
||||
query: string;
|
||||
reason: string;
|
||||
kind: 'page' | 'recent' | 'strategy';
|
||||
preferredModes?: readonly SearchExperienceMode[];
|
||||
};
|
||||
type SearchContextPanelView = {
|
||||
title: string;
|
||||
description: string;
|
||||
tokens: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
};
|
||||
type RescueActionView = {
|
||||
id: 'scope' | 'related' | 'reformulate' | 'page-context';
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-global-search',
|
||||
@@ -77,6 +101,39 @@ type SearchDomainFilter = 'all' | UnifiedSearchDomain;
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="search__experience-bar">
|
||||
<div class="search__experience-copy">
|
||||
<div class="search__experience-title">{{ t('ui.search.mode.label', 'Mode') }}</div>
|
||||
<div class="search__experience-description">{{ experienceModeDescription() }}</div>
|
||||
</div>
|
||||
<div class="search__experience-controls">
|
||||
<div class="search__segmented" role="tablist" aria-label="Search mode">
|
||||
@for (mode of experienceModeOptions(); track mode.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="search__segment"
|
||||
data-role="search-mode"
|
||||
[class.search__segment--active]="experienceMode() === mode.id"
|
||||
[attr.aria-selected]="experienceMode() === mode.id"
|
||||
(click)="setSearchMode(mode.id)"
|
||||
[title]="mode.description"
|
||||
>
|
||||
{{ mode.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="search__scope-chip"
|
||||
data-role="search-scope"
|
||||
(click)="toggleSearchScope()"
|
||||
[title]="t('ui.search.scope.toggle', 'Toggle between page scope and all domains')"
|
||||
>
|
||||
{{ t('ui.search.scope.label', 'Scope') }}: {{ searchScopeLabel() }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isLoading()) {
|
||||
<div class="search__loading">{{ t('ui.search.loading', 'Searching...') }}</div>
|
||||
} @else if (query().trim().length >= 1 && cards().length === 0) {
|
||||
@@ -107,6 +164,22 @@ type SearchDomainFilter = 'all' | UnifiedSearchDomain;
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="search__rescue">
|
||||
<div class="search__group-label">{{ t('ui.search.rescue.label', 'Recover this search') }}</div>
|
||||
<div class="search__rescue-actions">
|
||||
@for (action of rescueActions(); track action.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="search__rescue-card"
|
||||
[attr.data-rescue-action]="action.id"
|
||||
(click)="runRescueAction(action.id)"
|
||||
>
|
||||
<span class="search__rescue-title">{{ action.label }}</span>
|
||||
<span class="search__rescue-description">{{ action.description }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else if (query().trim().length >= 1) {
|
||||
<div class="search__filters">
|
||||
@for (filter of availableDomainFilters(); track filter) {
|
||||
@@ -195,15 +268,40 @@ type SearchDomainFilter = 'all' | UnifiedSearchDomain;
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (searchContextPanel(); as contextPanel) {
|
||||
<div class="search__context-rail">
|
||||
<div class="search__group-label">{{ t('ui.search.context.label', 'Context') }}</div>
|
||||
<div class="search__context-card">
|
||||
<div class="search__context-title">{{ contextPanel.title }}</div>
|
||||
<div class="search__context-description">{{ contextPanel.description }}</div>
|
||||
<div class="search__context-tokens">
|
||||
@for (token of contextPanel.tokens; track token.key) {
|
||||
<span class="search__context-token">
|
||||
<span class="search__context-token-label">{{ token.label }}:</span>
|
||||
<span class="search__context-token-value"> {{ token.value }}</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="search__suggestions">
|
||||
<div class="search__group-label">{{ t('ui.search.suggested_label', 'Suggested') }}</div>
|
||||
<div class="search__suggestion-chips">
|
||||
@for (suggestion of contextualSuggestions(); track suggestion) {
|
||||
<button
|
||||
type="button"
|
||||
class="search__chip"
|
||||
(click)="applyExampleQuery(suggestion)"
|
||||
>{{ suggestion }}</button>
|
||||
<div class="search__suggestion-cards">
|
||||
@for (suggestion of contextualSuggestions(); track suggestion.query) {
|
||||
<div
|
||||
class="search__suggestion-card"
|
||||
[class.search__suggestion-card--recent]="suggestion.kind === 'recent'"
|
||||
[class.search__suggestion-card--strategy]="suggestion.kind === 'strategy'"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="search__chip search__chip--contextual"
|
||||
(click)="applyExampleQuery(suggestion.query)"
|
||||
>{{ suggestion.query }}</button>
|
||||
<div class="search__suggestion-reason">{{ suggestion.reason }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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<HTMLElement>);
|
||||
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<void>();
|
||||
private readonly searchTerms$ = new Subject<string>();
|
||||
@@ -679,6 +1018,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
readonly searchResponse = signal<UnifiedSearchResponse | null>(null);
|
||||
readonly recentSearches = signal<string[]>([]);
|
||||
readonly activeDomainFilter = signal<SearchDomainFilter>('all');
|
||||
readonly searchScope = signal<SearchScopeMode>('page');
|
||||
readonly expandedCardKey = signal<string | null>(null);
|
||||
readonly pendingDomainFilter = signal<SearchDomainFilter | null>(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<string[]>(() =>
|
||||
this.ambientContext
|
||||
.getSearchSuggestions()
|
||||
.map((suggestion) => this.i18n.tryT(suggestion.key) ?? suggestion.fallback));
|
||||
readonly searchContextPanel = computed<SearchContextPanelView | null>(() => {
|
||||
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<SearchSuggestionView[]>(() => {
|
||||
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<RescueActionView[]>(() => {
|
||||
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 {
|
||||
|
||||
@@ -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<ChatMessageComponent>;
|
||||
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!);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ChatComponent>;
|
||||
let component: ChatComponent;
|
||||
let searchExperienceMode: SearchExperienceModeService;
|
||||
|
||||
const conversationState = signal<Conversation | null>(null);
|
||||
const isLoadingState = signal(false);
|
||||
const isStreamingState = signal(false);
|
||||
const streamingContentState = signal('');
|
||||
const errorState = signal<string | null>(null);
|
||||
const streamEvents$ = new Subject<StreamEvent>();
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
}),
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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<unknown>;
|
||||
let router: { url: string; events: Subject<unknown>; navigateByUrl: jasmine.Spy; navigate: jasmine.Spy };
|
||||
let searchChatContext: jasmine.SpyObj<SearchChatContextService>;
|
||||
let searchExperienceMode: SearchExperienceModeService;
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
routerEvents = new Subject<unknown>();
|
||||
router = {
|
||||
url: '/security/triage',
|
||||
@@ -52,15 +55,51 @@ describe('GlobalSearchComponent', () => {
|
||||
|
||||
ambientContext = jasmine.createSpyObj('AmbientContextService', [
|
||||
'buildContextFilter',
|
||||
'getSearchContextPanel',
|
||||
'getSearchSuggestions',
|
||||
'buildAmbientContext',
|
||||
'recordAction',
|
||||
]) as jasmine.SpyObj<AmbientContextService>;
|
||||
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<Element>,
|
||||
).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`,
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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<Record<string, unknown>> = [];
|
||||
await page.route('**/search/query**', async (route) => {
|
||||
const request = route.request().postDataJSON() as Record<string, unknown>;
|
||||
capturedRequests.push(request);
|
||||
|
||||
const query = String(request['q'] ?? '').toLowerCase();
|
||||
const filters = request['filters'] as Record<string, unknown> | 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<Record<string, unknown>> = [];
|
||||
await page.route('**/search/query**', async (route) => {
|
||||
const request = route.request().postDataJSON() as Record<string, unknown>;
|
||||
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<string, unknown> | undefined;
|
||||
const lastAction = ambient?.['lastAction'] as Record<string, unknown> | 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<void> {
|
||||
await page.route('**/search/query**', async (route) => {
|
||||
const body = route.request().postDataJSON() as Record<string, unknown>;
|
||||
const query = String(body['q'] ?? '').toLowerCase();
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(resolve(query)),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function mockChatConversation(
|
||||
page: Page,
|
||||
response: {
|
||||
content: string;
|
||||
citations: Array<{ type: string; path: string; verified: boolean }>;
|
||||
groundingScore: number;
|
||||
},
|
||||
): Promise<void> {
|
||||
await page.route('**/api/v1/advisory-ai/conversations', async (route) => {
|
||||
if (route.request().method() !== 'POST') {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
conversationId: 'conv-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,
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user