Improve search and advisory UX flows

This commit is contained in:
master
2026-03-06 19:13:26 +02:00
parent 06e10883ab
commit 9b86ad825a
20 changed files with 2530 additions and 123 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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)

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 '/';

View File

@@ -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' })

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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';

View File

@@ -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();

View File

@@ -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')

View File

@@ -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 {

View File

@@ -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!);
});
});

View File

@@ -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');
});
});

View File

@@ -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',
}),
]));
});
});

View File

@@ -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]);
});
});

View File

@@ -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`,

View File

@@ -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 ({

View File

@@ -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,
});
});
}