From 44c2b896e757e966e7eb6057f115a7816da05aca Mon Sep 17 00:00:00 2001 From: master <> Date: Sat, 7 Mar 2026 17:14:02 +0200 Subject: [PATCH] user settings and breadcrumb fixes --- docs/modules/advisory-ai/knowledge-search.md | 17 +- .../unified-search-architecture.md | 15 + docs/operations/unified-search-operations.md | 20 +- src/AdvisoryAI/__Tests/INFRASTRUCTURE.md | 29 +- .../app/core/navigation/navigation.config.ts | 14 +- .../settings/ai-preferences.component.ts | 54 +- .../app/features/settings/settings.routes.ts | 14 +- .../user-preferences-page.component.ts | 463 ++++++++++++++++++ .../layout/app-topbar/app-topbar.component.ts | 54 ++ .../layout/breadcrumb/breadcrumb.component.ts | 11 +- .../user-menu/user-menu.component.scss | 104 ---- .../user-menu/user-menu.component.ts | 128 +---- 12 files changed, 645 insertions(+), 278 deletions(-) create mode 100644 src/Web/StellaOps.Web/src/app/features/settings/user-preferences/user-preferences-page.component.ts diff --git a/docs/modules/advisory-ai/knowledge-search.md b/docs/modules/advisory-ai/knowledge-search.md index f339a22ff..67387b896 100644 --- a/docs/modules/advisory-ai/knowledge-search.md +++ b/docs/modules/advisory-ai/knowledge-search.md @@ -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. diff --git a/docs/modules/advisory-ai/unified-search-architecture.md b/docs/modules/advisory-ai/unified-search-architecture.md index 3acfb72d8..59aaf767f 100644 --- a/docs/modules/advisory-ai/unified-search-architecture.md +++ b/docs/modules/advisory-ai/unified-search-architecture.md @@ -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 diff --git a/docs/operations/unified-search-operations.md b/docs/operations/unified-search-operations.md index 3dcf7ae51..1bc479f4c 100644 --- a/docs/operations/unified-search-operations.md +++ b/docs/operations/unified-search-operations.md @@ -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 ``` diff --git a/src/AdvisoryAI/__Tests/INFRASTRUCTURE.md b/src/AdvisoryAI/__Tests/INFRASTRUCTURE.md index 4b3ff3cb9..a3d24bf12 100644 --- a/src/AdvisoryAI/__Tests/INFRASTRUCTURE.md +++ b/src/AdvisoryAI/__Tests/INFRASTRUCTURE.md @@ -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` 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 ``` --- diff --git a/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts b/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts index 2c1ece06c..b066235c8 100644 --- a/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts +++ b/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts @@ -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', }, ]; diff --git a/src/Web/StellaOps.Web/src/app/features/settings/ai-preferences.component.ts b/src/Web/StellaOps.Web/src/app/features/settings/ai-preferences.component.ts index d8ccec7f6..bf8aeffda 100644 --- a/src/Web/StellaOps.Web/src/app/features/settings/ai-preferences.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/settings/ai-preferences.component.ts @@ -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: `
-
-

AI Assistant Preferences

-

- Configure how AI assistance appears across StellaOps -

-
+ @if (!embedded()) { +
+

AI Assistant Preferences

+

+ Configure how AI assistance appears across StellaOps +

+
+ }
@@ -172,17 +174,19 @@ export const DEFAULT_AI_PREFERENCES: AiPreferences = { } -
- - -
+ @if (!embedded()) { +
+ + +
+ }
`, @@ -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 { diff --git a/src/Web/StellaOps.Web/src/app/features/settings/settings.routes.ts b/src/Web/StellaOps.Web/src/app/features/settings/settings.routes.ts index 6021710cf..d4be8c5d3 100644 --- a/src/Web/StellaOps.Web/src/app/features/settings/settings.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/settings/settings.routes.ts @@ -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', diff --git a/src/Web/StellaOps.Web/src/app/features/settings/user-preferences/user-preferences-page.component.ts b/src/Web/StellaOps.Web/src/app/features/settings/user-preferences/user-preferences-page.component.ts new file mode 100644 index 000000000..8b1ec8998 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/settings/user-preferences/user-preferences-page.component.ts @@ -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: ` +
+

User Preferences

+

Personalize your console experience

+ + +
+
+ +

Appearance

+
+

Choose how the console looks. System mode follows your OS preference.

+ +
+ @for (opt of themeOptions; track opt.value) { + + } +
+
+ + +
+
+ +

Language

+
+

Changes apply immediately. Your preference is synced across devices when signed in.

+ + + + @if (localeStatus(); as msg) { +

{{ msg }}

+ } +
+ + +
+
+ +

Layout

+
+

Control the default layout of the console sidebar.

+ +
+
+ Sidebar collapsed by default + Start with the sidebar minimized to icons only +
+ +
+
+ + +
+
+ +

AI Assistant

+
+

Configure how AI assistance appears across StellaOps.

+ + + +
+ +
+
+
+ `, + 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([...SUPPORTED_LOCALES]); + readonly localeSaving = signal(false); + readonly localeSaveState = signal('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 { + 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 { + const locales = await this.localeCatalog.getAvailableLocalesAsync(SUPPORTED_LOCALES); + this.localeOptions.set([...locales]); + } +} diff --git a/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts index 7f8b2721a..bb882ff9f 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts @@ -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 + + + @@ -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); diff --git a/src/Web/StellaOps.Web/src/app/layout/breadcrumb/breadcrumb.component.ts b/src/Web/StellaOps.Web/src/app/layout/breadcrumb/breadcrumb.component.ts index ee71f097d..c345f2113 100644 --- a/src/Web/StellaOps.Web/src/app/layout/breadcrumb/breadcrumb.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/breadcrumb/breadcrumb.component.ts @@ -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); diff --git a/src/Web/StellaOps.Web/src/app/shared/components/user-menu/user-menu.component.scss b/src/Web/StellaOps.Web/src/app/shared/components/user-menu/user-menu.component.scss index 396c9552c..0b9194e3a 100644 --- a/src/Web/StellaOps.Web/src/app/shared/components/user-menu/user-menu.component.scss +++ b/src/Web/StellaOps.Web/src/app/shared/components/user-menu/user-menu.component.scss @@ -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; } diff --git a/src/Web/StellaOps.Web/src/app/shared/components/user-menu/user-menu.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/user-menu/user-menu.component.ts index 1a28ca0fc..05e16d8a9 100644 --- a/src/Web/StellaOps.Web/src/app/shared/components/user-menu/user-menu.component.ts +++ b/src/Web/StellaOps.Web/src/app/shared/components/user-menu/user-menu.component.ts @@ -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
- -
- Theme -
- - - -
-
- - -
- {{ localeLabel() }} - -
- -
-