user settings and breadcrumb fixes

This commit is contained in:
master
2026-03-07 17:14:02 +02:00
parent 1fa2e69032
commit 44c2b896e7
12 changed files with 645 additions and 278 deletions

View File

@@ -142,6 +142,8 @@ Implemented in `src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSea
- Query telemetry:
- Unified search emits hashed query telemetry (`SHA-256` query hash, intent, domain weights, latency, top domains) via `IUnifiedSearchTelemetrySink`.
- Search analytics persistence stores hashed query keys (`SHA-256`, normalized) and pseudonymous user keys (tenant+user hash) in analytics/feedback artifacts.
- Self-serve analytics is optional and privacy-preserving: when clients emit `answer_frame`, `reformulation`, or `rescue_action`, persistence stores a tenant-scoped hashed session id plus bounded answer metadata (`answer_status`, `answer_code`) instead of raw prompt history.
- Quality metrics surface self-serve gaps as `fallbackAnswerRate`, `clarifyRate`, `insufficientRate`, `reformulationCount`, `rescueActionCount`, and `abandonedFallbackCount`; alerting adds `fallback_loop` and `abandoned_fallback` signals for backlog review.
- Free-form feedback comments are redacted at persistence time to avoid storing potential PII in analytics tables.
- Server-side search history remains user-facing functionality (raw query for history UX) and is keyed by pseudonymous user hash.
- Web fallback behavior: when unified search fails, `UnifiedSearchClient` falls back to legacy AKS (`/v1/advisory-ai/search`) and maps grouped legacy results into unified cards (`diagnostics.mode = legacy-fallback`).
@@ -294,22 +296,25 @@ Run the full suite:
dotnet test "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" -v normal
```
Targeted runs in this project use xUnit v3 / Microsoft.Testing.Platform.
Do not use VSTest `dotnet test --filter ...` syntax here; use xUnit pass-through or the built test executable instead.
Run only the search sprint integration tests:
```bash
dotnet test "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" \
--filter "FullyQualifiedName~UnifiedSearchSprintIntegrationTests" -v normal
-- --filter-class StellaOps.AdvisoryAI.Tests.Integration.UnifiedSearchSprintIntegrationTests
```
Run only the FTS recall benchmark:
```bash
dotnet test "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" \
--filter "FullyQualifiedName~FtsRecallBenchmarkTests" -v normal
-- --filter-class StellaOps.AdvisoryAI.Tests.KnowledgeSearch.FtsRecallBenchmarkTests
```
Run only the semantic recall benchmark:
```bash
dotnet test "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" \
--filter "FullyQualifiedName~SemanticRecallBenchmarkTests" -v normal
-- --filter-class StellaOps.AdvisoryAI.Tests.KnowledgeSearch.SemanticRecallBenchmarkTests
```
**For live database tests** (e.g., full AKS rebuild + query against real Postgres with pg_trgm/pgvector):
@@ -334,8 +339,9 @@ curl -X POST http://127.0.0.1:10451/v1/search/index/rebuild \
-H "X-StellaOps-Tenant: test-tenant"
# Run tests with the Live category (requires database)
dotnet test "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" \
--filter "Category=Live" -v normal
dotnet build "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" -v minimal
src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/bin/Debug/net10.0/StellaOps.AdvisoryAI.Tests.exe \
-trait "Category=Live" -reporter verbose -noColor
```
### CLI setup in a source checkout
@@ -401,6 +407,7 @@ The search sprints added several migrations under `src/AdvisoryAI/StellaOps.Advi
| `005_search_feedback.sql` | G10 (110) | `search_feedback` + `search_quality_alerts` tables |
| `005_search_analytics.sql` | G6 (106) | `search_events` + `search_history` tables |
| `007_multilingual_fts.sql` | G9 (109) | `body_tsv_de`, `body_tsv_fr`, `body_tsv_es`, `body_tsv_ru` tsvector columns + GIN indexes |
| `008_search_self_serve_analytics.sql` | AI-SELF-004 | `session_id`, `answer_status`, `answer_code` analytics columns plus self-serve indexes |
All migrations are idempotent (IF NOT EXISTS guards). They run automatically via `EnsureSchemaAsync()` at service startup.

View File

@@ -63,6 +63,21 @@ flowchart LR
- bounded follow-up `questions`
- The answer envelope is additive and optional so older clients remain compatible.
### Telemetry and gap surfacing
- Search analytics stays optional at the client layer; queries still work when analytics events are never emitted.
- When enabled, the self-serve lane records `answer_frame`, `reformulation`, and `rescue_action` with hashed query keys, hashed tenant-scoped session ids, and bounded answer metadata.
- Quality review surfaces:
- `GET /v1/advisory-ai/search/quality/metrics`
- `GET /v1/advisory-ai/search/quality/alerts`
- Current self-serve gap signals:
- fallback answer rate
- clarify rate
- insufficient-evidence rate
- reformulation count
- rescue-action count
- abandoned fallback count
- `fallback_loop` and `abandoned_fallback` alerts
## Data Flow
```mermaid

View File

@@ -8,7 +8,8 @@ Runbook for AdvisoryAI unified search setup, operations, troubleshooting, perfor
2. Configure `AdvisoryAI:UnifiedSearch` options.
3. Ensure model artifact path exists when `VectorEncoderType=onnx`:
- default: `models/all-MiniLM-L6-v2.onnx`
4. Rebuild index:
4. Rebuild indexes in order when verifying live search quality:
- `POST /v1/advisory-ai/index/rebuild`
- `POST /v1/search/index/rebuild`
5. Verify query endpoint:
- `POST /v1/search/query` with `X-StellaOps-Tenant` and `advisory-ai:operate` scope.
@@ -24,8 +25,11 @@ Runbook for AdvisoryAI unified search setup, operations, troubleshooting, perfor
## Monitoring
Track per-tenant and global:
- Query throughput (`query`, `click`, `zero_result`, `synthesis` events)
- Self-serve journey signals (`answer_frame`, `reformulation`, `rescue_action`)
- P50/P95/P99 latency for `/v1/search/query`
- Zero-result rate
- Fallback answer rate, clarify rate, insufficient-evidence rate
- Reformulation count, rescue-action count, abandoned fallback count
- Synthesis quota denials
- Index size and rebuild duration
- Active encoder diagnostics (`diagnostics.activeEncoder`)
@@ -104,6 +108,7 @@ Example:
- Verify tenant header is present.
- Verify `UnifiedSearch.Enabled` and tenant flag `Enabled`.
- Run index rebuild and check chunk count.
- If suggestions also fail, verify both rebuild steps were run in order and re-check with a known live query such as `database connectivity`.
### Symptom: poor semantic recall
- Verify `VectorEncoderType` and active encoder diagnostics.
@@ -114,6 +119,12 @@ Example:
- Check `SynthesisEnabled` (global + tenant).
- Check quota counters and provider configuration.
### Symptom: search feels self-serve weak
- Inspect `GET /v1/advisory-ai/search/quality/metrics?period=7d`.
- Watch `fallbackAnswerRate`, `clarifyRate`, `insufficientRate`, `reformulationCount`, `rescueActionCount`, and `abandonedFallbackCount`.
- Inspect `GET /v1/advisory-ai/search/quality/alerts` for `fallback_loop` and `abandoned_fallback`.
- Treat repeated fallback loops as ranking/context gaps; treat abandoned fallback sessions as UX/product gaps.
### Symptom: high latency
- Check federated backend timeout budget.
- Review `EXPLAIN (ANALYZE)` plans.
@@ -140,4 +151,11 @@ dotnet test src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.Advisory
# Performance envelope
dotnet test src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj \
-- --filter-class StellaOps.AdvisoryAI.Tests.UnifiedSearch.UnifiedSearchPerformanceEnvelopeTests
# Self-serve telemetry and gap surfacing slice
dotnet build src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj -v minimal
src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/bin/Debug/net10.0/StellaOps.AdvisoryAI.Tests.exe \
-method "StellaOps.AdvisoryAI.Tests.Integration.UnifiedSearchSprintIntegrationTests.G10_SelfServeMetrics_IncludeFallbackReformulationAndRescueSignals" \
-method "StellaOps.AdvisoryAI.Tests.Integration.UnifiedSearchSprintIntegrationTests.G10_RecoveredFallbackSessions_DoNotCountAsAbandoned" \
-reporter verbose -noColor
```

View File

@@ -28,6 +28,30 @@ These tests run entirely in-memory. No Docker, no database, no network. Just `do
dotnet test "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" -v normal
```
### Targeted runs in this repo
This test project uses xUnit v3 on Microsoft.Testing.Platform.
Do not rely on VSTest-style `dotnet test --filter ...` syntax here; it is ignored for this project.
Use one of these targeting patterns instead:
```bash
# Class-level targeting through dotnet test pass-through
dotnet test "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" \
-- --filter-class StellaOps.AdvisoryAI.Tests.Integration.UnifiedSearchSprintIntegrationTests
# Build once, then target individual classes, methods, or traits with the xUnit runner
dotnet build "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" -v minimal
src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/bin/Debug/net10.0/StellaOps.AdvisoryAI.Tests.exe \
-class "StellaOps.AdvisoryAI.Tests.Integration.UnifiedSearchSprintIntegrationTests"
src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/bin/Debug/net10.0/StellaOps.AdvisoryAI.Tests.exe \
-method "StellaOps.AdvisoryAI.Tests.Integration.UnifiedSearchSprintIntegrationTests.G10_SelfServeMetrics_IncludeFallbackReformulationAndRescueSignals"
src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/bin/Debug/net10.0/StellaOps.AdvisoryAI.Tests.exe \
-trait "Category=Live"
```
### Why no infrastructure is needed
All integration tests use `WebApplicationFactory<Program>` with **stubbed** services:
- `IKnowledgeSearchService``StubKnowledgeSearchService` (returns hardcoded results)
@@ -215,8 +239,9 @@ public sealed class KnowledgeSearchLiveTests : IAsyncLifetime
Run:
```bash
export ADVISORYAI_TEST_CONNSTRING="Host=localhost;Port=55432;Database=advisoryai_knowledge_test;Username=stellaops_knowledge;Password=stellaops_knowledge"
dotnet test "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" \
--filter "Category=Live" -v normal
dotnet build "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" -v minimal
src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/bin/Debug/net10.0/StellaOps.AdvisoryAI.Tests.exe \
-trait "Category=Live" -reporter verbose -noColor
```
---

View File

@@ -644,22 +644,10 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
* User menu items (profile dropdown)
*/
export const USER_MENU_ITEMS = [
{
id: 'profile',
label: 'Profile',
route: '/console/profile',
icon: 'user',
},
{
id: 'settings',
label: 'Settings',
route: '/settings',
icon: 'settings',
},
{
id: 'language',
label: 'Language',
route: '/settings/language',
route: '/settings/user-preferences',
icon: 'settings',
},
];

View File

@@ -9,7 +9,7 @@
* - Per-team notification opt-in
*/
import { Component, input, output, signal, effect } from '@angular/core';
import { Component, input, output, signal, effect, computed } from '@angular/core';
import { FormsModule } from '@angular/forms';
@@ -73,12 +73,14 @@ export const DEFAULT_AI_PREFERENCES: AiPreferences = {
imports: [FormsModule],
template: `
<div class="ai-preferences">
<header class="ai-preferences__header">
<h2 class="ai-preferences__title">AI Assistant Preferences</h2>
<p class="ai-preferences__description">
Configure how AI assistance appears across StellaOps
</p>
</header>
@if (!embedded()) {
<header class="ai-preferences__header">
<h2 class="ai-preferences__title">AI Assistant Preferences</h2>
<p class="ai-preferences__description">
Configure how AI assistance appears across StellaOps
</p>
</header>
}
<div class="ai-preferences__sections">
<!-- Verbosity Section -->
@@ -172,17 +174,19 @@ export const DEFAULT_AI_PREFERENCES: AiPreferences = {
}
<!-- Actions -->
<div class="ai-preferences__actions">
<button class="ai-preferences__button ai-preferences__button--secondary"
(click)="onReset()">
Reset to Defaults
</button>
<button class="ai-preferences__button ai-preferences__button--primary"
[disabled]="!hasChanges()"
(click)="onSave()">
Save Preferences
</button>
</div>
@if (!embedded()) {
<div class="ai-preferences__actions">
<button class="ai-preferences__button ai-preferences__button--secondary"
(click)="onReset()">
Reset to Defaults
</button>
<button class="ai-preferences__button ai-preferences__button--primary"
[disabled]="!hasChanges()"
(click)="onSave()">
Save Preferences
</button>
</div>
}
</div>
</div>
`,
@@ -418,6 +422,11 @@ export class AiPreferencesComponent {
*/
readonly teams = input<{ teamId: string; teamName: string }[]>([]);
/**
* When true, hides header and action buttons; auto-emits on every change.
*/
readonly embedded = input(false);
/**
* Preferences changed and saved.
*/
@@ -495,6 +504,7 @@ export class AiPreferencesComponent {
onVerbosityChange(value: AiVerbosity): void {
this.currentPreferences.update(p => ({ ...p, verbosity: value }));
this.autoEmit();
}
onSurfaceChange(key: keyof AiSurfaceSettings, event: Event): void {
@@ -503,6 +513,7 @@ export class AiPreferencesComponent {
...p,
surfaces: { ...p.surfaces, [key]: checked }
}));
this.autoEmit();
}
onTeamNotificationChange(teamId: string, event: Event): void {
@@ -513,6 +524,13 @@ export class AiPreferencesComponent {
t.teamId === teamId ? { ...t, enabled } : t
)
}));
this.autoEmit();
}
private autoEmit(): void {
if (this.embedded()) {
this.save.emit(this.currentPreferences());
}
}
onReset(): void {

View File

@@ -11,7 +11,7 @@ export const SETTINGS_ROUTES: Routes = [
title: 'Settings',
loadComponent: () =>
import('./settings-page.component').then(m => m.SettingsPageComponent),
data: { breadcrumb: 'Settings' },
data: {},
children: [
{
path: '',
@@ -104,6 +104,13 @@ export const SETTINGS_ROUTES: Routes = [
import('./notifications/notifications-settings-page.component').then(m => m.NotificationsSettingsPageComponent),
data: { breadcrumb: 'Notifications' },
},
{
path: 'user-preferences',
title: 'User Preferences',
loadComponent: () =>
import('./user-preferences/user-preferences-page.component').then(m => m.UserPreferencesPageComponent),
data: { breadcrumb: 'User Preferences' },
},
{
path: 'language',
title: 'Language',
@@ -114,9 +121,8 @@ export const SETTINGS_ROUTES: Routes = [
{
path: 'ai-preferences',
title: 'AI Preferences',
loadComponent: () =>
import('./ai-preferences-workbench.component').then(m => m.AiPreferencesWorkbenchComponent),
data: { breadcrumb: 'AI Preferences' },
redirectTo: 'user-preferences',
pathMatch: 'full' as const,
},
{
path: 'policy',

View File

@@ -0,0 +1,463 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { ThemeService, ThemeMode } from '../../../core/services/theme.service';
import { AuthSessionStore } from '../../../core/auth/auth-session.store';
import { I18nService, LocaleCatalogService, SUPPORTED_LOCALES, UserLocalePreferenceService } from '../../../core/i18n';
import { SidebarPreferenceService } from '../../../layout/app-sidebar/sidebar-preference.service';
import { AiPreferencesComponent, type AiPreferences } from '../ai-preferences.component';
import { PlainLanguageToggleComponent } from '../../advisory-ai/plain-language-toggle.component';
type LocaleSaveState = 'idle' | 'saving' | 'saved' | 'syncFailed';
@Component({
selector: 'app-user-preferences-page',
standalone: true,
imports: [AiPreferencesComponent, PlainLanguageToggleComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="prefs">
<h1 class="prefs__title">User Preferences</h1>
<p class="prefs__subtitle">Personalize your console experience</p>
<!-- Appearance -->
<section class="prefs__card">
<div class="prefs__card-header">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<circle cx="12" cy="12" r="5" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
<h2>Appearance</h2>
</div>
<p class="prefs__description">Choose how the console looks. System mode follows your OS preference.</p>
<div class="prefs__theme-grid" role="radiogroup" aria-label="Theme selection">
@for (opt of themeOptions; track opt.value) {
<button
type="button"
class="prefs__theme-option"
[class.prefs__theme-option--active]="themeService.mode() === opt.value"
role="radio"
[attr.aria-checked]="themeService.mode() === opt.value"
(click)="themeService.setMode(opt.value)"
>
<span class="prefs__theme-icon">
@switch (opt.value) {
@case ('light') {
<svg viewBox="0 0 24 24" width="24" height="24"><circle cx="12" cy="12" r="5" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" fill="none" stroke="currentColor" stroke-width="2"/></svg>
}
@case ('dark') {
<svg viewBox="0 0 24 24" width="24" height="24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" fill="none" stroke="currentColor" stroke-width="2"/></svg>
}
@case ('system') {
<svg viewBox="0 0 24 24" width="24" height="24"><rect x="2" y="3" width="20" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><line x1="8" y1="21" x2="16" y2="21" stroke="currentColor" stroke-width="2"/><line x1="12" y1="17" x2="12" y2="21" stroke="currentColor" stroke-width="2"/></svg>
}
}
</span>
<span class="prefs__theme-label">{{ opt.label }}</span>
@if (opt.value === 'system') {
<span class="prefs__theme-hint">({{ themeService.systemPreference() === 'dark' ? 'Dark' : 'Light' }})</span>
}
@if (themeService.mode() === opt.value) {
<svg class="prefs__check" viewBox="0 0 24 24" width="16" height="16"><polyline points="20 6 9 17 4 12" fill="none" stroke="currentColor" stroke-width="2.5"/></svg>
}
</button>
}
</div>
</section>
<!-- Language -->
<section class="prefs__card">
<div class="prefs__card-header">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="2" y1="12" x2="22" y2="12" stroke="currentColor" stroke-width="2"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
<h2>Language</h2>
</div>
<p class="prefs__description">Changes apply immediately. Your preference is synced across devices when signed in.</p>
<label class="prefs__label" for="locale-select">Preferred language</label>
<select
id="locale-select"
class="prefs__select"
[value]="currentLocale()"
[disabled]="localeSaving()"
(change)="onLocaleSelected($event)"
>
@for (locale of localeOptions(); track locale) {
<option [value]="locale">{{ localeDisplayName(locale) }}</option>
}
</select>
@if (localeStatus(); as msg) {
<p class="prefs__status" [class.prefs__status--warn]="localeSaveState() === 'syncFailed'">{{ msg }}</p>
}
</section>
<!-- Layout -->
<section class="prefs__card">
<div class="prefs__card-header">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="9" y1="3" x2="9" y2="21" stroke="currentColor" stroke-width="2"/>
</svg>
<h2>Layout</h2>
</div>
<p class="prefs__description">Control the default layout of the console sidebar.</p>
<div class="prefs__toggle-row">
<div class="prefs__toggle-info">
<span class="prefs__toggle-label">Sidebar collapsed by default</span>
<span class="prefs__toggle-hint">Start with the sidebar minimized to icons only</span>
</div>
<button
type="button"
role="switch"
class="prefs__switch"
[class.prefs__switch--on]="sidebarCollapsed()"
[attr.aria-checked]="sidebarCollapsed()"
(click)="sidebarPrefs.toggleSidebar()"
>
<span class="prefs__switch-thumb"></span>
</button>
</div>
</section>
<!-- AI Assistant -->
<section class="prefs__card">
<div class="prefs__card-header">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M12 2L2 7l10 5 10-5-10-5z" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M2 17l10 5 10-5" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M2 12l10 5 10-5" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
<h2>AI Assistant</h2>
</div>
<p class="prefs__description">Configure how AI assistance appears across StellaOps.</p>
<stella-ai-preferences
[initialPreferences]="aiInitialPreferences"
[teams]="aiTeams"
[embedded]="true"
(save)="onAiPreferencesSave($event)"
/>
<div class="prefs__ai-plain-language">
<stellaops-plain-language-toggle
label="Explain like I'm new"
description="Simplify technical jargon in AI guidance"
[showBadge]="true"
[initialValue]="false"
/>
</div>
</section>
</div>
`,
styles: [`
.prefs {
max-width: 720px;
}
.prefs__title {
margin: 0 0 0.25rem;
font-size: 1.5rem;
font-weight: var(--font-weight-semibold);
}
.prefs__subtitle {
margin: 0 0 1.75rem;
color: var(--color-text-secondary);
}
.prefs__card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.25rem;
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-primary);
background: var(--color-surface-primary);
}
.prefs__card-header {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--color-text-primary);
}
.prefs__card-header h2 {
margin: 0;
font-size: 1rem;
font-weight: var(--font-weight-semibold);
}
.prefs__description {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.8125rem;
line-height: 1.5;
}
/* Theme grid */
.prefs__theme-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
margin-top: 0.25rem;
}
.prefs__theme-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem 0.75rem;
border: 2px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
cursor: pointer;
position: relative;
transition: border-color 0.15s, background 0.15s, color 0.15s;
}
.prefs__theme-option:hover {
border-color: var(--color-border-secondary);
color: var(--color-text-primary);
}
.prefs__theme-option:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
.prefs__theme-option--active {
border-color: var(--color-brand-primary);
background: color-mix(in srgb, var(--color-brand-primary) 8%, var(--color-surface-primary));
color: var(--color-text-primary);
}
.prefs__theme-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
}
.prefs__theme-label {
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
}
.prefs__theme-hint {
font-size: 0.6875rem;
color: var(--color-text-tertiary);
}
.prefs__check {
position: absolute;
top: 0.5rem;
right: 0.5rem;
color: var(--color-brand-primary);
}
/* Language */
.prefs__label {
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
}
.prefs__select {
width: min(320px, 100%);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-primary);
font-size: 0.875rem;
padding: 0.5rem 0.625rem;
}
.prefs__select:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
.prefs__status {
margin: 0;
font-size: 0.75rem;
color: var(--color-status-success-text);
}
.prefs__status--warn {
color: var(--color-status-warning-text);
}
/* Toggle / Switch */
.prefs__toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.prefs__toggle-info {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.prefs__toggle-label {
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
}
.prefs__toggle-hint {
font-size: 0.75rem;
color: var(--color-text-tertiary);
}
.prefs__switch {
position: relative;
width: 40px;
height: 22px;
border: none;
border-radius: 11px;
background: var(--color-surface-tertiary);
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s;
padding: 0;
}
.prefs__switch--on {
background: var(--color-brand-primary);
}
.prefs__switch-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: white;
transition: transform 0.15s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.prefs__switch--on .prefs__switch-thumb {
transform: translateX(18px);
}
.prefs__switch:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
/* AI section */
.prefs__ai-plain-language {
padding-top: 0.5rem;
border-top: 1px solid var(--color-border-primary);
}
@media (max-width: 480px) {
.prefs__theme-grid {
grid-template-columns: 1fr;
}
}
`],
})
export class UserPreferencesPageComponent {
protected readonly themeService = inject(ThemeService);
protected readonly sidebarPrefs = inject(SidebarPreferenceService);
private readonly i18n = inject(I18nService);
private readonly localeCatalog = inject(LocaleCatalogService);
private readonly localePreference = inject(UserLocalePreferenceService);
private readonly authSession = inject(AuthSessionStore);
readonly currentLocale = this.i18n.locale;
readonly sidebarCollapsed = this.sidebarPrefs.sidebarCollapsed;
readonly localeOptions = signal<string[]>([...SUPPORTED_LOCALES]);
readonly localeSaving = signal(false);
readonly localeSaveState = signal<LocaleSaveState>('idle');
readonly aiInitialPreferences: AiPreferences = {
verbosity: 'standard',
surfaces: { showInUi: true, showInPrComments: false, showInNotifications: false },
teamNotifications: [
{ teamId: 'team-platform', teamName: 'Platform Team', enabled: false },
{ teamId: 'team-security', teamName: 'Security Team', enabled: false },
],
};
readonly aiTeams = [
{ teamId: 'team-platform', teamName: 'Platform Team' },
{ teamId: 'team-security', teamName: 'Security Team' },
];
readonly themeOptions: Array<{ value: ThemeMode; label: string }> = [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'System' },
];
readonly localeStatus = computed(() => {
const state = this.localeSaveState();
if (state === 'saving') return 'Saving...';
if (state === 'syncFailed') return 'Saved locally, but account sync failed.';
if (state === 'saved') {
return this.authSession.isAuthenticated()
? 'Saved to your account.'
: 'Saved locally. Sign in to sync across devices.';
}
return null;
});
constructor() {
void this.loadLocaleOptions();
}
async onLocaleSelected(event: Event): Promise<void> {
const selected = (event.target as HTMLSelectElement | null)?.value?.trim();
if (!selected || selected === this.currentLocale()) return;
this.localeSaving.set(true);
this.localeSaveState.set('saving');
try {
await this.i18n.setLocale(selected);
if (this.authSession.isAuthenticated()) {
try {
await this.localePreference.setLocaleAsync(selected);
this.localeSaveState.set('saved');
} catch {
this.localeSaveState.set('syncFailed');
}
} else {
this.localeSaveState.set('saved');
}
} finally {
this.localeSaving.set(false);
}
}
onAiPreferencesSave(_preferences: AiPreferences): void {
// Will be wired to a backend persistence endpoint
}
localeDisplayName(locale: string): string {
const key = `ui.locale.${locale.toLowerCase().replaceAll('-', '_')}`;
return this.i18n.tryT(key) ?? locale;
}
private async loadLocaleOptions(): Promise<void> {
const locales = await this.localeCatalog.getAvailableLocalesAsync(SUPPORTED_LOCALES);
this.localeOptions.set([...locales]);
}
}

View File

@@ -18,6 +18,7 @@ import { filter } from 'rxjs/operators';
import { AuthSessionStore } from '../../core/auth/auth-session.store';
import { ConsoleSessionService } from '../../core/console/console-session.service';
import { ConsoleSessionStore } from '../../core/console/console-session.store';
import { ThemeService } from '../../core/services/theme.service';
import { GlobalSearchComponent } from '../global-search/global-search.component';
import { ContextChipsComponent } from '../context-chips/context-chips.component';
@@ -89,6 +90,32 @@ import { I18nService, UserLocalePreferenceService } from '../../core/i18n';
Context
</button>
<!-- Theme toggle -->
<button
type="button"
class="topbar__theme-toggle"
[attr.aria-label]="'Theme: ' + themeService.modeLabel() + '. Click to cycle.'"
[title]="themeService.modeLabel()"
(click)="themeService.cycle()"
>
@if (themeService.mode() === 'dark') {
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
} @else if (themeService.mode() === 'system') {
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<rect x="2" y="3" width="20" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="8" y1="21" x2="16" y2="21" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="17" x2="12" y2="21" stroke="currentColor" stroke-width="2"/>
</svg>
} @else {
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<circle cx="12" cy="12" r="5" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
</button>
<!-- User menu -->
<app-user-menu></app-user-menu>
</div>
@@ -313,6 +340,32 @@ import { I18nService, UserLocalePreferenceService } from '../../core/i18n';
flex-shrink: 0;
}
.topbar__theme-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid transparent;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
transition: background-color 0.12s, color 0.12s, border-color 0.12s;
}
.topbar__theme-toggle:hover {
background: var(--color-nav-hover);
color: var(--color-text-primary);
border-color: var(--color-border-primary);
}
.topbar__theme-toggle:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: -2px;
}
.topbar__primary-action {
display: inline-flex;
align-items: center;
@@ -533,6 +586,7 @@ export class AppTopbarComponent {
private readonly consoleStore = inject(ConsoleSessionStore);
private readonly i18n = inject(I18nService);
private readonly localePreference = inject(UserLocalePreferenceService);
protected readonly themeService = inject(ThemeService);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
private readonly elementRef = inject(ElementRef<HTMLElement>);

View File

@@ -198,10 +198,13 @@ export class BreadcrumbComponent implements OnInit, OnDestroy {
const label = child.snapshot.data['breadcrumb'];
if (label) {
breadcrumbs.push({
label,
route: url,
});
const prev = breadcrumbs[breadcrumbs.length - 1];
if (!prev || prev.label !== label || prev.route !== url) {
breadcrumbs.push({
label,
route: url,
});
}
}
return this.buildBreadcrumbs(child, url, breadcrumbs);

View File

@@ -204,114 +204,10 @@
flex-shrink: 0;
}
// =============================================================================
// Theme Toggle
// =============================================================================
.user-menu__theme {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
}
.user-menu__theme-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.user-menu__theme-options {
display: flex;
gap: var(--space-1);
padding: 2px;
background-color: var(--color-surface-tertiary);
border-radius: var(--radius-sm);
}
.user-menu__theme-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
color: var(--color-text-muted);
background: none;
border: none;
border-radius: var(--radius-xs);
cursor: pointer;
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
color var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
color: var(--color-text-primary);
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: -2px;
}
&--active {
background-color: var(--color-brand-primary);
color: var(--color-text-inverse);
&:hover {
background-color: var(--color-brand-primary-hover);
}
}
}
// =============================================================================
// Locale Selector
// =============================================================================
.user-menu__locale {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
}
.user-menu__locale-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.user-menu__locale-select {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-tertiary);
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-family: var(--font-family-mono);
letter-spacing: 0.02em;
min-width: 90px;
padding: 4px 6px;
cursor: pointer;
&:hover {
border-color: var(--color-border-secondary);
color: var(--color-text-primary);
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.user-menu__trigger,
.user-menu__item,
.user-menu__theme-btn,
.user-menu__chevron {
transition: none;
}

View File

@@ -1,12 +1,9 @@
import { Component, inject, HostListener, signal, computed, effect, DestroyRef } from '@angular/core';
import { Component, inject, HostListener, signal } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { AUTH_SERVICE, AuthService } from '../../../core/auth';
import { AuthSessionStore } from '../../../core/auth/auth-session.store';
import { ThemeService } from '../../../core/services/theme.service';
import { NavigationService } from '../../../core/navigation';
import { I18nService, LocaleCatalogService, SUPPORTED_LOCALES, UserLocalePreferenceService } from '../../../core/i18n';
/**
* User menu dropdown component.
@@ -87,75 +84,6 @@ import { I18nService, LocaleCatalogService, SUPPORTED_LOCALES, UserLocalePrefere
<div class="user-menu__divider"></div>
<!-- Theme Toggle -->
<div class="user-menu__theme">
<span class="user-menu__theme-label">Theme</span>
<div class="user-menu__theme-options" role="radiogroup" aria-label="Theme selection">
<button
type="button"
class="user-menu__theme-btn"
[class.user-menu__theme-btn--active]="themeService.mode() === 'light'"
(click)="themeService.setMode('light')"
role="radio"
[attr.aria-checked]="themeService.mode() === 'light'"
aria-label="Light theme"
title="Light"
>
<svg viewBox="0 0 24 24" width="16" height="16">
<circle cx="12" cy="12" r="5" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
<button
type="button"
class="user-menu__theme-btn"
[class.user-menu__theme-btn--active]="themeService.mode() === 'dark'"
(click)="themeService.setMode('dark')"
role="radio"
[attr.aria-checked]="themeService.mode() === 'dark'"
aria-label="Dark theme"
title="Dark"
>
<svg viewBox="0 0 24 24" width="16" height="16">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
<button
type="button"
class="user-menu__theme-btn"
[class.user-menu__theme-btn--active]="themeService.mode() === 'system'"
(click)="themeService.setMode('system')"
role="radio"
[attr.aria-checked]="themeService.mode() === 'system'"
aria-label="System theme"
title="System"
>
<svg viewBox="0 0 24 24" width="16" height="16">
<rect x="2" y="3" width="20" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="8" y1="21" x2="16" y2="21" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="17" x2="12" y2="21" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
</div>
</div>
<!-- Locale Selector -->
<div class="user-menu__locale">
<span class="user-menu__locale-label">{{ localeLabel() }}</span>
<select
class="user-menu__locale-select"
[value]="currentLocale()"
[attr.aria-label]="'Language selector. Current: ' + currentLocale()"
(change)="onLocaleSelected($event)"
>
@for (locale of localeOptions(); track locale) {
<option [value]="locale">{{ localeDisplayName(locale) }}</option>
}
</select>
</div>
<div class="user-menu__divider"></div>
<!-- Sign Out -->
<button
type="button"
@@ -180,63 +108,9 @@ import { I18nService, LocaleCatalogService, SUPPORTED_LOCALES, UserLocalePrefere
})
export class UserMenuComponent {
protected readonly authService = inject(AUTH_SERVICE) as AuthService;
protected readonly themeService = inject(ThemeService);
protected readonly navService = inject(NavigationService);
private readonly i18n = inject(I18nService);
private readonly localeCatalog = inject(LocaleCatalogService);
private readonly localePreference = inject(UserLocalePreferenceService);
private readonly sessionStore = inject(AuthSessionStore);
private readonly destroyRef = inject(DestroyRef);
protected readonly menuOpen = signal(false);
readonly currentLocale = this.i18n.locale;
readonly localeLabel = computed(() => this.i18n.tryT('ui.locale.label') ?? 'Language');
private readonly availableLocales = signal<string[]>([...SUPPORTED_LOCALES]);
private readonly localeCatalogSyncAttempted = signal(false);
readonly localeOptions = computed(() => {
const current = this.currentLocale();
const options = [...this.availableLocales()];
if (current && !options.includes(current)) {
options.unshift(current);
}
return options;
});
constructor() {
effect(() => {
const authenticated = this.sessionStore.isAuthenticated();
if (!authenticated) {
this.localeCatalogSyncAttempted.set(false);
this.availableLocales.set([...SUPPORTED_LOCALES]);
return;
}
if (!this.localeCatalogSyncAttempted()) {
this.localeCatalogSyncAttempted.set(true);
void this.loadLocaleCatalog();
}
});
}
async onLocaleSelected(event: Event): Promise<void> {
const selectedLocale = (event.target as HTMLSelectElement | null)?.value?.trim();
if (!selectedLocale || selectedLocale === this.currentLocale()) {
return;
}
await this.i18n.setLocale(selectedLocale);
if (this.sessionStore.isAuthenticated()) {
void this.localePreference.setLocaleAsync(selectedLocale).catch(() => undefined);
}
}
localeDisplayName(locale: string): string {
const key = `ui.locale.${locale.toLowerCase().replaceAll('-', '_')}`;
return this.i18n.tryT(key) ?? locale;
}
private async loadLocaleCatalog(): Promise<void> {
const locales = await this.localeCatalog.getAvailableLocalesAsync(SUPPORTED_LOCALES);
this.availableLocales.set([...locales]);
}
protected readonly displayName = () => {
const user = this.authService.user();