Add unified Stella Assistant: mascot + search + AI chat as one
Merge three disconnected help surfaces (Stella mascot, Ctrl+K search, Advisory AI chat) into one unified assistant. Mascot is the face, search is its memory, AI chat is its voice. Backend: - DB schema (060/061): tips, greetings, glossary, tours, user_state tables with 189 tips + 101 greetings seed data - REST API: GET tips/glossary/tours, GET/PUT user-state with longest-prefix route matching and locale fallback - Admin endpoints: CRUD for tips, glossary, tours (SetupAdmin policy) Frontend: - StellaAssistantService: unified mode management (tips/search/chat), API-backed tips with static fallback, i18n integration - Three-mode mascot component: tips, inline search, embedded chat - StellaGlossaryDirective: DB-backed tooltip annotations for domain terms - Admin tip editor: CRUD for tips/glossary/tours in Console Admin - Tour player: step-through guided tours with element highlighting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
namespace StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
// ─── Tips ────────────────────────────────────────────────────────────────────
|
||||
|
||||
public sealed record AssistantTipDto(
|
||||
string TipId,
|
||||
string Title,
|
||||
string Body,
|
||||
AssistantTipActionDto? Action,
|
||||
string? ContextTrigger);
|
||||
|
||||
public sealed record AssistantTipActionDto(string Label, string Route);
|
||||
|
||||
public sealed record AssistantTipsResponse(
|
||||
string Greeting,
|
||||
AssistantTipDto[] Tips,
|
||||
AssistantTipDto[] ContextTips);
|
||||
|
||||
// ─── Glossary ────────────────────────────────────────────────────────────────
|
||||
|
||||
public sealed record GlossaryTermDto(
|
||||
string TermId,
|
||||
string Term,
|
||||
string Definition,
|
||||
string? ExtendedHelp,
|
||||
string[] RelatedTerms,
|
||||
string[] RelatedRoutes);
|
||||
|
||||
public sealed record GlossaryResponse(GlossaryTermDto[] Terms);
|
||||
|
||||
// ─── Tours ───────────────────────────────────────────────────────────────────
|
||||
|
||||
public sealed record TourDto(
|
||||
string TourId,
|
||||
string TourKey,
|
||||
string Title,
|
||||
string Description,
|
||||
object[] Steps);
|
||||
|
||||
public sealed record ToursResponse(TourDto[] Tours);
|
||||
|
||||
// ─── User State ──────────────────────────────────────────────────────────────
|
||||
|
||||
public sealed record AssistantUserStateDto(
|
||||
string[] SeenRoutes,
|
||||
string[] CompletedTours,
|
||||
Dictionary<string, int> TipPositions,
|
||||
bool Dismissed);
|
||||
|
||||
// ─── Admin ───────────────────────────────────────────────────────────────────
|
||||
|
||||
public sealed record UpsertAssistantTipRequest(
|
||||
string RoutePattern,
|
||||
string? ContextTrigger,
|
||||
string Locale,
|
||||
int SortOrder,
|
||||
string Title,
|
||||
string Body,
|
||||
string? ActionLabel,
|
||||
string? ActionRoute,
|
||||
string? LearnMoreUrl,
|
||||
bool IsActive,
|
||||
string? ProductVersion);
|
||||
|
||||
public sealed record UpsertGlossaryTermRequest(
|
||||
string Term,
|
||||
string Locale,
|
||||
string Definition,
|
||||
string? ExtendedHelp,
|
||||
string[] RelatedTerms,
|
||||
string[] RelatedRoutes,
|
||||
bool IsActive);
|
||||
|
||||
// Admin listing — includes metadata for editing
|
||||
public sealed record AssistantTipAdminDto(
|
||||
string TipId,
|
||||
string RoutePattern,
|
||||
string? ContextTrigger,
|
||||
string Locale,
|
||||
int SortOrder,
|
||||
string Title,
|
||||
string Body,
|
||||
string? ActionLabel,
|
||||
string? ActionRoute,
|
||||
bool IsActive,
|
||||
string CreatedBy,
|
||||
string UpdatedAt);
|
||||
|
||||
public sealed record UpsertTourRequest(
|
||||
string TourKey,
|
||||
string Title,
|
||||
string Description,
|
||||
string Locale,
|
||||
object[] Steps,
|
||||
bool IsActive);
|
||||
|
||||
public sealed record TourAdminDto(
|
||||
string TourId,
|
||||
string TourKey,
|
||||
string Title,
|
||||
string Description,
|
||||
string Locale,
|
||||
int StepCount,
|
||||
bool IsActive,
|
||||
string CreatedAt);
|
||||
@@ -0,0 +1,229 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Stella Assistant API — DB-backed, locale-aware contextual help for the mascot.
|
||||
/// Serves tips, glossary, tours, and persists user state.
|
||||
/// </summary>
|
||||
public static class AssistantEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAssistantEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/stella-assistant")
|
||||
.WithTags("Stella Assistant")
|
||||
.RequireAuthorization(PlatformPolicies.PreferencesRead);
|
||||
|
||||
// ─── Tips ────────────────────────────────────────────────────────
|
||||
|
||||
group.MapGet("/tips", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
[FromQuery] string route,
|
||||
[FromQuery] string? locale,
|
||||
[FromQuery] string? contexts,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var effectiveLocale = locale ?? "en-US";
|
||||
var contextList = string.IsNullOrWhiteSpace(contexts)
|
||||
? Array.Empty<string>()
|
||||
: contexts.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
var result = await store.GetTipsAsync(route, effectiveLocale, contextList, tenantId, ct);
|
||||
return Results.Ok(result);
|
||||
})
|
||||
.WithName("GetAssistantTips")
|
||||
.WithSummary("Get contextual tips for a route and locale");
|
||||
|
||||
// ─── Glossary ────────────────────────────────────────────────────
|
||||
|
||||
group.MapGet("/glossary", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
[FromQuery] string? locale,
|
||||
[FromQuery] string? terms,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var effectiveLocale = locale ?? "en-US";
|
||||
var termList = string.IsNullOrWhiteSpace(terms)
|
||||
? null
|
||||
: terms.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
var result = await store.GetGlossaryAsync(effectiveLocale, tenantId, termList, ct);
|
||||
return Results.Ok(result);
|
||||
})
|
||||
.WithName("GetAssistantGlossary")
|
||||
.WithSummary("Get domain glossary terms for a locale");
|
||||
|
||||
// ─── User State ──────────────────────────────────────────────────
|
||||
|
||||
group.MapGet("/user-state", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var (userId, tenantId) = ResolveUserContext(httpContext);
|
||||
var state = await store.GetUserStateAsync(userId, tenantId, ct);
|
||||
return state is not null ? Results.Ok(state) : Results.Ok(new AssistantUserStateDto(
|
||||
Array.Empty<string>(), Array.Empty<string>(), new Dictionary<string, int>(), false));
|
||||
})
|
||||
.WithName("GetAssistantUserState")
|
||||
.WithSummary("Get user's assistant preferences and seen routes");
|
||||
|
||||
group.MapPut("/user-state", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
AssistantUserStateDto state,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var (userId, tenantId) = ResolveUserContext(httpContext);
|
||||
await store.UpsertUserStateAsync(userId, tenantId, state, ct);
|
||||
return Results.Ok();
|
||||
})
|
||||
.WithName("UpdateAssistantUserState")
|
||||
.WithSummary("Persist user's assistant preferences")
|
||||
.RequireAuthorization(PlatformPolicies.PreferencesWrite);
|
||||
|
||||
// ─── Tours ───────────────────────────────────────────────────────
|
||||
|
||||
group.MapGet("/tours", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
[FromQuery] string? locale,
|
||||
[FromQuery] string? tourKey,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var effectiveLocale = locale ?? "en-US";
|
||||
var result = await store.GetToursAsync(effectiveLocale, tenantId, tourKey, ct);
|
||||
return Results.Ok(result);
|
||||
})
|
||||
.WithName("GetAssistantTours")
|
||||
.WithSummary("Get guided tour definitions");
|
||||
|
||||
// ─── Admin CRUD ──────────────────────────────────────────────────
|
||||
|
||||
var admin = app.MapGroup("/api/v1/stella-assistant/admin")
|
||||
.WithTags("Stella Assistant Admin")
|
||||
.RequireAuthorization(PlatformPolicies.SetupAdmin);
|
||||
|
||||
admin.MapPost("/tips", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
UpsertAssistantTipRequest request,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var actor = ResolveUserId(httpContext);
|
||||
var id = await store.UpsertTipAsync(tenantId, request, actor, ct);
|
||||
return Results.Ok(new { tipId = id });
|
||||
})
|
||||
.WithName("UpsertAssistantTip")
|
||||
.WithSummary("Create or update a tip");
|
||||
|
||||
admin.MapDelete("/tips/{tipId}", async Task<IResult>(
|
||||
string tipId,
|
||||
PostgresAssistantStore store,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
await store.DeactivateTipAsync(tipId, ct);
|
||||
return Results.Ok();
|
||||
})
|
||||
.WithName("DeactivateAssistantTip")
|
||||
.WithSummary("Deactivate a tip");
|
||||
|
||||
admin.MapGet("/tips", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
[FromQuery] string? locale,
|
||||
[FromQuery] string? route,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var result = await store.ListAllTipsAsync(tenantId, locale ?? "en-US", route, ct);
|
||||
return Results.Ok(result);
|
||||
})
|
||||
.WithName("ListAllAssistantTips")
|
||||
.WithSummary("List all tips for admin editing");
|
||||
|
||||
admin.MapGet("/tours", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
[FromQuery] string? locale,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var result = await store.ListAllToursAsync(tenantId, locale ?? "en-US", ct);
|
||||
return Results.Ok(result);
|
||||
})
|
||||
.WithName("ListAllAssistantTours")
|
||||
.WithSummary("List all tours for admin editing");
|
||||
|
||||
admin.MapPost("/tours", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
UpsertTourRequest request,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var id = await store.UpsertTourAsync(tenantId, request, ct);
|
||||
return Results.Ok(new { tourId = id });
|
||||
})
|
||||
.WithName("UpsertAssistantTour")
|
||||
.WithSummary("Create or update a guided tour");
|
||||
|
||||
admin.MapGet("/tours/{tourKey}", async Task<IResult>(
|
||||
string tourKey,
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
[FromQuery] string? locale,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var tour = await store.GetTourByKeyAsync(tenantId, tourKey, locale ?? "en-US", ct);
|
||||
return tour is not null ? Results.Ok(tour) : Results.NotFound();
|
||||
})
|
||||
.WithName("GetAssistantTourByKey")
|
||||
.WithSummary("Get a single tour by key for editing");
|
||||
|
||||
admin.MapPost("/glossary", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
UpsertGlossaryTermRequest request,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var id = await store.UpsertGlossaryTermAsync(tenantId, request, ct);
|
||||
return Results.Ok(new { termId = id });
|
||||
})
|
||||
.WithName("UpsertGlossaryTerm")
|
||||
.WithSummary("Create or update a glossary term");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static string ResolveUserId(HttpContext ctx)
|
||||
=> ctx.User.FindFirst("sub")?.Value
|
||||
?? ctx.User.FindFirst("stellaops:user_id")?.Value
|
||||
?? "anonymous";
|
||||
|
||||
private static string ResolveTenantId(HttpContext ctx)
|
||||
=> ctx.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system";
|
||||
|
||||
private static (string UserId, string TenantId) ResolveUserContext(HttpContext ctx)
|
||||
{
|
||||
var userId = ctx.User.FindFirst("sub")?.Value
|
||||
?? ctx.User.FindFirst("stellaops:user_id")?.Value
|
||||
?? "anonymous";
|
||||
var tenantId = ResolveTenantId(ctx);
|
||||
return (userId, tenantId);
|
||||
}
|
||||
}
|
||||
@@ -244,6 +244,7 @@ if (!string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString
|
||||
builder.Services.AddSingleton<IAdministrationTrustSigningStore, PostgresAdministrationTrustSigningStore>();
|
||||
builder.Services.AddSingleton<IPlatformContextStore, PostgresPlatformContextStore>();
|
||||
builder.Services.AddSingleton<ITranslationStore, PostgresTranslationStore>();
|
||||
builder.Services.AddSingleton<PostgresAssistantStore>();
|
||||
|
||||
// Auto-migrate platform schemas on startup
|
||||
builder.Services.AddStartupMigrations<PlatformServiceOptions>(
|
||||
@@ -337,6 +338,7 @@ app.Use(async (context, next) =>
|
||||
await app.LoadTranslationsAsync();
|
||||
|
||||
app.MapLocalizationEndpoints();
|
||||
app.MapAssistantEndpoints();
|
||||
app.MapEnvironmentSettingsEndpoints();
|
||||
app.MapEnvironmentSettingsAdminEndpoints();
|
||||
app.MapContextEndpoints();
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
using System.Text.Json;
|
||||
using Npgsql;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL store for the Stella Assistant (tips, greetings, glossary, tours, user state).
|
||||
/// </summary>
|
||||
public sealed class PostgresAssistantStore
|
||||
{
|
||||
private readonly NpgsqlDataSource _ds;
|
||||
|
||||
public PostgresAssistantStore(NpgsqlDataSource dataSource)
|
||||
=> _ds = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
|
||||
// ─── Tips ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Resolve tips for a given route, locale, and optional context triggers.
|
||||
/// Uses longest-prefix matching: /ops/policy/vex/consensus matches before /ops/policy/vex.
|
||||
/// Falls back to en-US if no tips exist for the requested locale.
|
||||
/// </summary>
|
||||
public async Task<AssistantTipsResponse> GetTipsAsync(
|
||||
string route, string locale, string[] contexts, string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = await _ds.OpenConnectionAsync(ct);
|
||||
|
||||
// Greeting
|
||||
var greeting = await GetGreetingAsync(conn, route, locale, tenantId, ct);
|
||||
|
||||
// Page tips (longest prefix match)
|
||||
var tips = await GetPageTipsAsync(conn, route, locale, tenantId, ct);
|
||||
|
||||
// Context-triggered tips
|
||||
var contextTips = contexts.Length > 0
|
||||
? await GetContextTipsAsync(conn, contexts, locale, tenantId, ct)
|
||||
: Array.Empty<AssistantTipDto>();
|
||||
|
||||
return new AssistantTipsResponse(greeting, tips, contextTips);
|
||||
}
|
||||
|
||||
private async Task<string> GetGreetingAsync(
|
||||
NpgsqlConnection conn, string route, string locale, string tenantId, CancellationToken ct)
|
||||
{
|
||||
// Try exact match, then progressively shorter prefixes
|
||||
var prefixes = BuildPrefixes(route);
|
||||
|
||||
const string sql = @"
|
||||
SELECT greeting_text FROM platform.assistant_greetings
|
||||
WHERE route_pattern = ANY(@routes)
|
||||
AND locale = @locale
|
||||
AND (tenant_id = @tenantId OR tenant_id = '_system')
|
||||
AND is_active
|
||||
ORDER BY length(route_pattern) DESC, tenant_id DESC
|
||||
LIMIT 1";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@routes", prefixes);
|
||||
cmd.Parameters.AddWithValue("@locale", locale);
|
||||
cmd.Parameters.AddWithValue("@tenantId", tenantId);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(ct) as string;
|
||||
|
||||
// Fallback to en-US if not found
|
||||
if (result is null && locale != "en-US")
|
||||
{
|
||||
cmd.Parameters["@locale"].Value = "en-US";
|
||||
result = await cmd.ExecuteScalarAsync(ct) as string;
|
||||
}
|
||||
|
||||
return result ?? "Hi! I'm Stella, your DevOps guide.";
|
||||
}
|
||||
|
||||
private async Task<AssistantTipDto[]> GetPageTipsAsync(
|
||||
NpgsqlConnection conn, string route, string locale, string tenantId, CancellationToken ct)
|
||||
{
|
||||
var prefixes = BuildPrefixes(route);
|
||||
|
||||
const string sql = @"
|
||||
WITH matched AS (
|
||||
SELECT t.*, length(t.route_pattern) AS specificity,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY t.sort_order
|
||||
ORDER BY length(t.route_pattern) DESC, t.tenant_id DESC
|
||||
) AS rn
|
||||
FROM platform.assistant_tips t
|
||||
WHERE t.route_pattern = ANY(@routes)
|
||||
AND t.locale = @locale
|
||||
AND (t.tenant_id = @tenantId OR t.tenant_id = '_system')
|
||||
AND t.is_active
|
||||
AND t.context_trigger IS NULL
|
||||
)
|
||||
SELECT tip_id, title, body, action_label, action_route, context_trigger
|
||||
FROM matched WHERE rn = 1
|
||||
ORDER BY sort_order";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@routes", prefixes);
|
||||
cmd.Parameters.AddWithValue("@locale", locale);
|
||||
cmd.Parameters.AddWithValue("@tenantId", tenantId);
|
||||
|
||||
var tips = new List<AssistantTipDto>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
tips.Add(MapTip(reader));
|
||||
|
||||
// Fallback to en-US
|
||||
if (tips.Count == 0 && locale != "en-US")
|
||||
{
|
||||
await reader.DisposeAsync();
|
||||
cmd.Parameters["@locale"].Value = "en-US";
|
||||
await using var reader2 = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader2.ReadAsync(ct))
|
||||
tips.Add(MapTip(reader2));
|
||||
}
|
||||
|
||||
return tips.ToArray();
|
||||
}
|
||||
|
||||
private async Task<AssistantTipDto[]> GetContextTipsAsync(
|
||||
NpgsqlConnection conn, string[] contexts, string locale, string tenantId, CancellationToken ct)
|
||||
{
|
||||
const string sql = @"
|
||||
SELECT tip_id, title, body, action_label, action_route, context_trigger
|
||||
FROM platform.assistant_tips
|
||||
WHERE context_trigger = ANY(@contexts)
|
||||
AND locale = @locale
|
||||
AND (tenant_id = @tenantId OR tenant_id = '_system')
|
||||
AND is_active
|
||||
ORDER BY sort_order";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@contexts", contexts);
|
||||
cmd.Parameters.AddWithValue("@locale", locale);
|
||||
cmd.Parameters.AddWithValue("@tenantId", tenantId);
|
||||
|
||||
var tips = new List<AssistantTipDto>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
tips.Add(MapTip(reader));
|
||||
|
||||
if (tips.Count == 0 && locale != "en-US")
|
||||
{
|
||||
await reader.DisposeAsync();
|
||||
cmd.Parameters["@locale"].Value = "en-US";
|
||||
await using var reader2 = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader2.ReadAsync(ct))
|
||||
tips.Add(MapTip(reader2));
|
||||
}
|
||||
|
||||
return tips.ToArray();
|
||||
}
|
||||
|
||||
// ─── Glossary ────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<GlossaryResponse> GetGlossaryAsync(
|
||||
string locale, string? tenantId, string[]? terms, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = await _ds.OpenConnectionAsync(ct);
|
||||
var tid = tenantId ?? "_system";
|
||||
|
||||
var sql = @"
|
||||
SELECT term_id, term, definition, extended_help, related_terms, related_routes
|
||||
FROM platform.assistant_glossary
|
||||
WHERE locale = @locale
|
||||
AND (tenant_id = @tid OR tenant_id = '_system')
|
||||
AND is_active";
|
||||
|
||||
if (terms is { Length: > 0 })
|
||||
sql += " AND term = ANY(@terms)";
|
||||
|
||||
sql += " ORDER BY term";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@locale", locale);
|
||||
cmd.Parameters.AddWithValue("@tid", tid);
|
||||
if (terms is { Length: > 0 })
|
||||
cmd.Parameters.AddWithValue("@terms", terms);
|
||||
|
||||
var result = new List<GlossaryTermDto>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
result.Add(new GlossaryTermDto(
|
||||
reader.GetString(0),
|
||||
reader.GetString(1),
|
||||
reader.GetString(2),
|
||||
reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
reader.GetFieldValue<string[]>(4),
|
||||
reader.GetFieldValue<string[]>(5)));
|
||||
}
|
||||
|
||||
// Fallback to en-US
|
||||
if (result.Count == 0 && locale != "en-US")
|
||||
{
|
||||
await reader.DisposeAsync();
|
||||
cmd.Parameters["@locale"].Value = "en-US";
|
||||
await using var reader2 = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader2.ReadAsync(ct))
|
||||
{
|
||||
result.Add(new GlossaryTermDto(
|
||||
reader2.GetString(0),
|
||||
reader2.GetString(1),
|
||||
reader2.GetString(2),
|
||||
reader2.IsDBNull(3) ? null : reader2.GetString(3),
|
||||
reader2.GetFieldValue<string[]>(4),
|
||||
reader2.GetFieldValue<string[]>(5)));
|
||||
}
|
||||
}
|
||||
|
||||
return new GlossaryResponse(result.ToArray());
|
||||
}
|
||||
|
||||
// ─── User State ──────────────────────────────────────────────────────
|
||||
|
||||
public async Task<AssistantUserStateDto?> GetUserStateAsync(
|
||||
string userId, string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = await _ds.OpenConnectionAsync(ct);
|
||||
const string sql = @"
|
||||
SELECT seen_routes, completed_tours, tip_positions, dismissed
|
||||
FROM platform.assistant_user_state
|
||||
WHERE user_id = @userId AND tenant_id = @tenantId";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@userId", userId);
|
||||
cmd.Parameters.AddWithValue("@tenantId", tenantId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct)) return null;
|
||||
|
||||
var tipPositions = reader.IsDBNull(2)
|
||||
? new Dictionary<string, int>()
|
||||
: JsonSerializer.Deserialize<Dictionary<string, int>>(reader.GetString(2))
|
||||
?? new Dictionary<string, int>();
|
||||
|
||||
return new AssistantUserStateDto(
|
||||
reader.GetFieldValue<string[]>(0),
|
||||
reader.GetFieldValue<string[]>(1),
|
||||
tipPositions,
|
||||
reader.GetBoolean(3));
|
||||
}
|
||||
|
||||
public async Task UpsertUserStateAsync(
|
||||
string userId, string tenantId, AssistantUserStateDto state, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = await _ds.OpenConnectionAsync(ct);
|
||||
const string sql = @"
|
||||
INSERT INTO platform.assistant_user_state (user_id, tenant_id, seen_routes, completed_tours, tip_positions, dismissed, last_seen_at)
|
||||
VALUES (@userId, @tenantId, @seenRoutes, @completedTours, @tipPositions::jsonb, @dismissed, now())
|
||||
ON CONFLICT (user_id, tenant_id) DO UPDATE SET
|
||||
seen_routes = EXCLUDED.seen_routes,
|
||||
completed_tours = EXCLUDED.completed_tours,
|
||||
tip_positions = EXCLUDED.tip_positions,
|
||||
dismissed = EXCLUDED.dismissed,
|
||||
last_seen_at = now()";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@userId", userId);
|
||||
cmd.Parameters.AddWithValue("@tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("@seenRoutes", state.SeenRoutes);
|
||||
cmd.Parameters.AddWithValue("@completedTours", state.CompletedTours);
|
||||
cmd.Parameters.AddWithValue("@tipPositions", JsonSerializer.Serialize(state.TipPositions));
|
||||
cmd.Parameters.AddWithValue("@dismissed", state.Dismissed);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
// ─── Tours ───────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<ToursResponse> GetToursAsync(
|
||||
string locale, string tenantId, string? tourKey, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = await _ds.OpenConnectionAsync(ct);
|
||||
var sql = @"
|
||||
SELECT tour_id, tour_key, title, description, steps
|
||||
FROM platform.assistant_tours
|
||||
WHERE locale = @locale
|
||||
AND (tenant_id = @tid OR tenant_id = '_system')
|
||||
AND is_active";
|
||||
if (tourKey is not null)
|
||||
sql += " AND tour_key = @tourKey";
|
||||
sql += " ORDER BY tour_key";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@locale", locale);
|
||||
cmd.Parameters.AddWithValue("@tid", tenantId);
|
||||
if (tourKey is not null)
|
||||
cmd.Parameters.AddWithValue("@tourKey", tourKey);
|
||||
|
||||
var tours = new List<TourDto>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
var stepsJson = reader.GetString(4);
|
||||
var steps = JsonSerializer.Deserialize<object[]>(stepsJson) ?? Array.Empty<object>();
|
||||
tours.Add(new TourDto(
|
||||
reader.GetGuid(0).ToString(),
|
||||
reader.GetString(1),
|
||||
reader.GetString(2),
|
||||
reader.GetString(3),
|
||||
steps));
|
||||
}
|
||||
|
||||
// Fallback to en-US
|
||||
if (tours.Count == 0 && locale != "en-US")
|
||||
{
|
||||
await reader.DisposeAsync();
|
||||
cmd.Parameters["@locale"].Value = "en-US";
|
||||
await using var r2 = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await r2.ReadAsync(ct))
|
||||
{
|
||||
var stepsJson = r2.GetString(4);
|
||||
var steps = JsonSerializer.Deserialize<object[]>(stepsJson) ?? Array.Empty<object>();
|
||||
tours.Add(new TourDto(r2.GetGuid(0).ToString(), r2.GetString(1), r2.GetString(2), r2.GetString(3), steps));
|
||||
}
|
||||
}
|
||||
|
||||
return new ToursResponse(tours.ToArray());
|
||||
}
|
||||
|
||||
// ─── Admin CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
public async Task<string> UpsertTipAsync(
|
||||
string tenantId, UpsertAssistantTipRequest req, string actor, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = await _ds.OpenConnectionAsync(ct);
|
||||
const string sql = @"
|
||||
INSERT INTO platform.assistant_tips
|
||||
(route_pattern, context_trigger, locale, sort_order, title, body,
|
||||
action_label, action_route, learn_more_url, is_active, product_version,
|
||||
tenant_id, created_by, updated_at)
|
||||
VALUES (@route, @ctx, @locale, @sort, @title, @body,
|
||||
@actionLabel, @actionRoute, @learnMore, @active, @version,
|
||||
@tid, @actor, now())
|
||||
ON CONFLICT ON CONSTRAINT ux_assistant_tips_route_ctx_locale_order
|
||||
DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
body = EXCLUDED.body,
|
||||
action_label = EXCLUDED.action_label,
|
||||
action_route = EXCLUDED.action_route,
|
||||
learn_more_url = EXCLUDED.learn_more_url,
|
||||
is_active = EXCLUDED.is_active,
|
||||
product_version = EXCLUDED.product_version,
|
||||
updated_at = now()
|
||||
RETURNING tip_id::text";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@route", req.RoutePattern);
|
||||
cmd.Parameters.AddWithValue("@ctx", (object?)req.ContextTrigger ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@locale", req.Locale);
|
||||
cmd.Parameters.AddWithValue("@sort", req.SortOrder);
|
||||
cmd.Parameters.AddWithValue("@title", req.Title);
|
||||
cmd.Parameters.AddWithValue("@body", req.Body);
|
||||
cmd.Parameters.AddWithValue("@actionLabel", (object?)req.ActionLabel ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@actionRoute", (object?)req.ActionRoute ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@learnMore", (object?)req.LearnMoreUrl ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@active", req.IsActive);
|
||||
cmd.Parameters.AddWithValue("@version", (object?)req.ProductVersion ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@tid", tenantId);
|
||||
cmd.Parameters.AddWithValue("@actor", actor);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(ct);
|
||||
return result?.ToString() ?? "";
|
||||
}
|
||||
|
||||
public async Task DeactivateTipAsync(string tipId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = await _ds.OpenConnectionAsync(ct);
|
||||
const string sql = "UPDATE platform.assistant_tips SET is_active = FALSE, updated_at = now() WHERE tip_id = @id::uuid";
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@id", tipId);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<AssistantTipAdminDto[]> ListAllTipsAsync(
|
||||
string tenantId, string locale, string? route, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = await _ds.OpenConnectionAsync(ct);
|
||||
var sql = @"
|
||||
SELECT tip_id, route_pattern, context_trigger, locale, sort_order,
|
||||
title, body, action_label, action_route, is_active,
|
||||
created_by, updated_at::text
|
||||
FROM platform.assistant_tips
|
||||
WHERE (tenant_id = @tid OR tenant_id = '_system')
|
||||
AND locale = @locale";
|
||||
if (route is not null)
|
||||
sql += " AND route_pattern = @route";
|
||||
sql += " ORDER BY route_pattern, sort_order";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@tid", tenantId);
|
||||
cmd.Parameters.AddWithValue("@locale", locale);
|
||||
if (route is not null)
|
||||
cmd.Parameters.AddWithValue("@route", route);
|
||||
|
||||
var tips = new List<AssistantTipAdminDto>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
tips.Add(new AssistantTipAdminDto(
|
||||
reader.GetGuid(0).ToString(),
|
||||
reader.GetString(1),
|
||||
reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
reader.GetString(3),
|
||||
reader.GetInt32(4),
|
||||
reader.GetString(5),
|
||||
reader.GetString(6),
|
||||
reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||
reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||
reader.GetBoolean(9),
|
||||
reader.GetString(10),
|
||||
reader.GetString(11)));
|
||||
}
|
||||
return tips.ToArray();
|
||||
}
|
||||
|
||||
public async Task<string> UpsertGlossaryTermAsync(
|
||||
string tenantId, UpsertGlossaryTermRequest req, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = await _ds.OpenConnectionAsync(ct);
|
||||
const string sql = @"
|
||||
INSERT INTO platform.assistant_glossary
|
||||
(term, locale, definition, extended_help, related_terms, related_routes, is_active, tenant_id)
|
||||
VALUES (@term, @locale, @def, @ext, @relTerms, @relRoutes, @active, @tid)
|
||||
ON CONFLICT ON CONSTRAINT ux_assistant_glossary_term_locale
|
||||
DO UPDATE SET
|
||||
definition = EXCLUDED.definition,
|
||||
extended_help = EXCLUDED.extended_help,
|
||||
related_terms = EXCLUDED.related_terms,
|
||||
related_routes = EXCLUDED.related_routes,
|
||||
is_active = EXCLUDED.is_active
|
||||
RETURNING term_id::text";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@term", req.Term);
|
||||
cmd.Parameters.AddWithValue("@locale", req.Locale);
|
||||
cmd.Parameters.AddWithValue("@def", req.Definition);
|
||||
cmd.Parameters.AddWithValue("@ext", (object?)req.ExtendedHelp ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@relTerms", req.RelatedTerms);
|
||||
cmd.Parameters.AddWithValue("@relRoutes", req.RelatedRoutes);
|
||||
cmd.Parameters.AddWithValue("@active", req.IsActive);
|
||||
cmd.Parameters.AddWithValue("@tid", tenantId);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(ct);
|
||||
return result?.ToString() ?? "";
|
||||
}
|
||||
|
||||
public async Task<TourAdminDto[]> ListAllToursAsync(
|
||||
string tenantId, string locale, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = await _ds.OpenConnectionAsync(ct);
|
||||
const string sql = @"
|
||||
SELECT tour_id, tour_key, title, description, locale,
|
||||
jsonb_array_length(steps), is_active, created_at::text
|
||||
FROM platform.assistant_tours
|
||||
WHERE (tenant_id = @tid OR tenant_id = '_system')
|
||||
AND locale = @locale
|
||||
ORDER BY tour_key";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@tid", tenantId);
|
||||
cmd.Parameters.AddWithValue("@locale", locale);
|
||||
|
||||
var tours = new List<TourAdminDto>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
tours.Add(new TourAdminDto(
|
||||
reader.GetGuid(0).ToString(),
|
||||
reader.GetString(1),
|
||||
reader.GetString(2),
|
||||
reader.GetString(3),
|
||||
reader.GetString(4),
|
||||
reader.GetInt32(5),
|
||||
reader.GetBoolean(6),
|
||||
reader.GetString(7)));
|
||||
}
|
||||
return tours.ToArray();
|
||||
}
|
||||
|
||||
public async Task<string> UpsertTourAsync(
|
||||
string tenantId, UpsertTourRequest req, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = await _ds.OpenConnectionAsync(ct);
|
||||
const string sql = @"
|
||||
INSERT INTO platform.assistant_tours
|
||||
(tour_key, locale, title, description, steps, is_active, tenant_id)
|
||||
VALUES (@key, @locale, @title, @desc, @steps::jsonb, @active, @tid)
|
||||
ON CONFLICT ON CONSTRAINT ux_assistant_tours_key_locale
|
||||
DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
steps = EXCLUDED.steps,
|
||||
is_active = EXCLUDED.is_active
|
||||
RETURNING tour_id::text";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@key", req.TourKey);
|
||||
cmd.Parameters.AddWithValue("@locale", req.Locale);
|
||||
cmd.Parameters.AddWithValue("@title", req.Title);
|
||||
cmd.Parameters.AddWithValue("@desc", req.Description);
|
||||
cmd.Parameters.AddWithValue("@steps", JsonSerializer.Serialize(req.Steps));
|
||||
cmd.Parameters.AddWithValue("@active", req.IsActive);
|
||||
cmd.Parameters.AddWithValue("@tid", tenantId);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(ct);
|
||||
return result?.ToString() ?? "";
|
||||
}
|
||||
|
||||
public async Task<TourDto?> GetTourByKeyAsync(
|
||||
string tenantId, string tourKey, string locale, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = await _ds.OpenConnectionAsync(ct);
|
||||
const string sql = @"
|
||||
SELECT tour_id, tour_key, title, description, steps
|
||||
FROM platform.assistant_tours
|
||||
WHERE tour_key = @key AND locale = @locale
|
||||
AND (tenant_id = @tid OR tenant_id = '_system')
|
||||
LIMIT 1";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@key", tourKey);
|
||||
cmd.Parameters.AddWithValue("@locale", locale);
|
||||
cmd.Parameters.AddWithValue("@tid", tenantId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct)) return null;
|
||||
|
||||
var stepsJson = reader.GetString(4);
|
||||
var steps = JsonSerializer.Deserialize<object[]>(stepsJson) ?? Array.Empty<object>();
|
||||
return new TourDto(
|
||||
reader.GetGuid(0).ToString(),
|
||||
reader.GetString(1),
|
||||
reader.GetString(2),
|
||||
reader.GetString(3),
|
||||
steps);
|
||||
}
|
||||
|
||||
/// <summary>Build all route prefixes for longest-prefix matching.</summary>
|
||||
private static string[] BuildPrefixes(string route)
|
||||
{
|
||||
// /ops/policy/vex/consensus → ["/ops/policy/vex/consensus", "/ops/policy/vex", "/ops/policy", "/ops", "/"]
|
||||
var prefixes = new List<string> { route };
|
||||
var path = route;
|
||||
while (path.Length > 1)
|
||||
{
|
||||
var lastSlash = path.LastIndexOf('/');
|
||||
if (lastSlash <= 0) break;
|
||||
path = path[..lastSlash];
|
||||
prefixes.Add(path);
|
||||
}
|
||||
prefixes.Add("/");
|
||||
return prefixes.ToArray();
|
||||
}
|
||||
|
||||
private static AssistantTipDto MapTip(NpgsqlDataReader reader)
|
||||
{
|
||||
var actionLabel = reader.IsDBNull(3) ? null : reader.GetString(3);
|
||||
var actionRoute = reader.IsDBNull(4) ? null : reader.GetString(4);
|
||||
var action = actionLabel is not null && actionRoute is not null
|
||||
? new AssistantTipActionDto(actionLabel, actionRoute)
|
||||
: null;
|
||||
|
||||
return new AssistantTipDto(
|
||||
reader.GetGuid(0).ToString(),
|
||||
reader.GetString(1),
|
||||
reader.GetString(2),
|
||||
action,
|
||||
reader.IsDBNull(5) ? null : reader.GetString(5));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
-- SPRINT_20260329_007 / Unified Stella Assistant
|
||||
-- DB-backed contextual tips, glossary, tours, and user state for the Stella mascot.
|
||||
-- Replaces hardcoded English-only tips with locale-aware, admin-editable content.
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS platform;
|
||||
|
||||
-- ─── Tips: page/tab-level contextual help ───────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform.assistant_tips (
|
||||
tip_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
route_pattern TEXT NOT NULL,
|
||||
context_trigger TEXT,
|
||||
locale VARCHAR(10) NOT NULL DEFAULT 'en-US',
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
action_label TEXT,
|
||||
action_route TEXT,
|
||||
learn_more_url TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
product_version VARCHAR(32),
|
||||
tenant_id VARCHAR(128) NOT NULL DEFAULT '_system',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by VARCHAR(256) NOT NULL DEFAULT 'system',
|
||||
|
||||
CONSTRAINT ux_assistant_tips_route_ctx_locale_order
|
||||
UNIQUE (tenant_id, route_pattern, context_trigger, locale, sort_order)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_assistant_tips_route_locale
|
||||
ON platform.assistant_tips (route_pattern, locale)
|
||||
WHERE is_active;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_assistant_tips_context
|
||||
ON platform.assistant_tips (context_trigger)
|
||||
WHERE context_trigger IS NOT NULL AND is_active;
|
||||
|
||||
-- ─── Greetings: per-page greeting text ──────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform.assistant_greetings (
|
||||
greeting_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
route_pattern TEXT NOT NULL,
|
||||
locale VARCHAR(10) NOT NULL DEFAULT 'en-US',
|
||||
greeting_text TEXT NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
tenant_id VARCHAR(128) NOT NULL DEFAULT '_system',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT ux_assistant_greetings_route_locale
|
||||
UNIQUE (tenant_id, route_pattern, locale)
|
||||
);
|
||||
|
||||
-- ─── Glossary: domain term definitions ──────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform.assistant_glossary (
|
||||
term_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
term VARCHAR(128) NOT NULL,
|
||||
locale VARCHAR(10) NOT NULL DEFAULT 'en-US',
|
||||
definition TEXT NOT NULL,
|
||||
extended_help TEXT,
|
||||
related_terms TEXT[] NOT NULL DEFAULT '{}',
|
||||
related_routes TEXT[] NOT NULL DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
tenant_id VARCHAR(128) NOT NULL DEFAULT '_system',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT ux_assistant_glossary_term_locale
|
||||
UNIQUE (tenant_id, term, locale)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_assistant_glossary_locale
|
||||
ON platform.assistant_glossary (locale)
|
||||
WHERE is_active;
|
||||
|
||||
-- ─── Tours: guided walkthroughs ─────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform.assistant_tours (
|
||||
tour_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tour_key VARCHAR(128) NOT NULL,
|
||||
locale VARCHAR(10) NOT NULL DEFAULT 'en-US',
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
steps JSONB NOT NULL DEFAULT '[]',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
tenant_id VARCHAR(128) NOT NULL DEFAULT '_system',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT ux_assistant_tours_key_locale
|
||||
UNIQUE (tenant_id, tour_key, locale)
|
||||
);
|
||||
|
||||
-- ─── User state: per-user mascot preferences ───────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform.assistant_user_state (
|
||||
user_id VARCHAR(256) NOT NULL,
|
||||
tenant_id VARCHAR(128) NOT NULL,
|
||||
seen_routes TEXT[] NOT NULL DEFAULT '{}',
|
||||
completed_tours TEXT[] NOT NULL DEFAULT '{}',
|
||||
tip_positions JSONB NOT NULL DEFAULT '{}',
|
||||
dismissed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
PRIMARY KEY (user_id, tenant_id)
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
799
src/Web/StellaOps.Web/e2e/stella-assistant.e2e.spec.ts
Normal file
799
src/Web/StellaOps.Web/e2e/stella-assistant.e2e.spec.ts
Normal file
@@ -0,0 +1,799 @@
|
||||
/**
|
||||
* Stella Assistant — Black-Box E2E Tests
|
||||
*
|
||||
* QA perspective: tests observable behavior only. No knowledge of internal
|
||||
* implementation, signals, services, or component structure.
|
||||
*
|
||||
* Persona: "Alex" — mid-level developer, new DevOps, first time using Stella Ops.
|
||||
* Tests verify that Alex can discover, use, and benefit from the assistant.
|
||||
*
|
||||
* Coverage:
|
||||
* 1. Mascot visibility & first impression
|
||||
* 2. Tips mode — contextual help per page
|
||||
* 3. Search mode — type-to-search, results, navigation
|
||||
* 4. Chat mode — ask questions, get answers
|
||||
* 5. Mode transitions — tips ↔ search ↔ chat
|
||||
* 6. Dismiss / restore behavior
|
||||
* 7. Persistence across page navigations
|
||||
* 8. Tour engine — guided walkthrough
|
||||
* 9. Admin editor (requires admin role)
|
||||
* 10. Glossary tooltips
|
||||
* 11. Context-driven tips (reacts to page state)
|
||||
* 12. Accessibility (keyboard, ARIA)
|
||||
* 13. Responsive (mobile viewport)
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures/auth.fixture';
|
||||
|
||||
const SCREENSHOT_DIR = 'e2e/screenshots/stella-assistant';
|
||||
|
||||
async function snap(page: import('@playwright/test').Page, label: string) {
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/${label}.png`, fullPage: false });
|
||||
}
|
||||
|
||||
async function go(page: import('@playwright/test').Page, path: string) {
|
||||
await page.goto(path, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 1. MASCOT VISIBILITY & FIRST IMPRESSION
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe('1 — Mascot Visibility', () => {
|
||||
test('1.1 mascot appears on dashboard after login', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
|
||||
// The mascot should be visible in the bottom-right corner
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await expect(mascot).toBeVisible({ timeout: 10_000 });
|
||||
await snap(page, '01-mascot-visible');
|
||||
});
|
||||
|
||||
test('1.2 mascot has recognizable avatar image', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
|
||||
const mascotImg = page.locator('img[alt*="Stella"]').last();
|
||||
await expect(mascotImg).toBeVisible();
|
||||
// Should have a non-zero rendered size
|
||||
const box = await mascotImg.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.width).toBeGreaterThan(20);
|
||||
expect(box!.height).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
test('1.3 mascot is present on every authenticated page', async ({ authenticatedPage: page }) => {
|
||||
const routes = [
|
||||
'/',
|
||||
'/releases',
|
||||
'/security',
|
||||
'/evidence/overview',
|
||||
'/ops/operations',
|
||||
'/setup/integrations',
|
||||
'/ops/policy/vex',
|
||||
];
|
||||
|
||||
for (const route of routes) {
|
||||
await go(page, route);
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await expect(mascot).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
});
|
||||
|
||||
test('1.4 mascot auto-opens bubble on first visit to a page', async ({ authenticatedPage: page }) => {
|
||||
// Clear any stored state to simulate first visit
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem('stellaops.assistant.state');
|
||||
localStorage.removeItem('stellaops.helper.preferences');
|
||||
});
|
||||
|
||||
await go(page, '/');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Bubble should auto-open with a tip
|
||||
const bubble = page.locator('[role="status"]').first();
|
||||
await expect(bubble).toBeVisible({ timeout: 5000 });
|
||||
await snap(page, '01-auto-open-first-visit');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 2. TIPS MODE — Contextual help per page
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe('2 — Tips Mode', () => {
|
||||
test('2.1 clicking mascot opens tip bubble', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Bubble should be visible with tip content
|
||||
const bubble = page.locator('[role="status"]');
|
||||
await expect(bubble).toBeVisible();
|
||||
|
||||
// Should contain a title (bold text about the current page)
|
||||
const tipText = await bubble.innerText();
|
||||
expect(tipText.length).toBeGreaterThan(20);
|
||||
await snap(page, '02-tip-bubble-open');
|
||||
});
|
||||
|
||||
test('2.2 dashboard tips mention dashboard concepts', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const tipText = await page.locator('[role="status"]').innerText();
|
||||
// Dashboard tips should reference dashboard-related concepts
|
||||
const dashboardTerms = ['dashboard', 'overview', 'environment', 'sbom', 'vulnerability', 'feed', 'status'];
|
||||
const hasRelevantTerm = dashboardTerms.some(term =>
|
||||
tipText.toLowerCase().includes(term)
|
||||
);
|
||||
expect(hasRelevantTerm, `Tip should mention a dashboard concept. Got: "${tipText.slice(0, 100)}"`).toBe(true);
|
||||
});
|
||||
|
||||
test('2.3 tips change when navigating to a different page', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const dashboardTip = await page.locator('[role="status"]').innerText();
|
||||
|
||||
// Navigate to VEX page
|
||||
await go(page, '/ops/policy/vex');
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const vexTip = await page.locator('[role="status"]').innerText();
|
||||
|
||||
// Tips should be different
|
||||
expect(vexTip).not.toEqual(dashboardTip);
|
||||
// VEX tip should mention VEX-related concepts
|
||||
const vexTerms = ['vex', 'vulnerability', 'exploitability', 'statement', 'affected'];
|
||||
const hasVexTerm = vexTerms.some(term => vexTip.toLowerCase().includes(term));
|
||||
expect(hasVexTerm, `VEX tip should mention VEX concepts. Got: "${vexTip.slice(0, 100)}"`).toBe(true);
|
||||
});
|
||||
|
||||
test('2.4 tip navigation (prev/next) cycles through tips', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should show tip counter "1 / N"
|
||||
const counter = page.locator('text=/\\d+ \\/ \\d+/');
|
||||
await expect(counter).toBeVisible();
|
||||
const counterText = await counter.innerText();
|
||||
expect(counterText).toMatch(/1 \/ \d+/);
|
||||
|
||||
// Get first tip title
|
||||
const firstTip = await page.locator('[role="status"]').innerText();
|
||||
|
||||
// Click Next
|
||||
const nextBtn = page.getByRole('button', { name: /next tip/i });
|
||||
await expect(nextBtn).toBeEnabled();
|
||||
await nextBtn.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Counter should update
|
||||
const newCounter = await counter.innerText();
|
||||
expect(newCounter).toMatch(/2 \/ \d+/);
|
||||
|
||||
// Tip content should change
|
||||
const secondTip = await page.locator('[role="status"]').innerText();
|
||||
expect(secondTip).not.toEqual(firstTip);
|
||||
|
||||
// Click Prev
|
||||
const prevBtn = page.getByRole('button', { name: /previous tip/i });
|
||||
await expect(prevBtn).toBeEnabled();
|
||||
await prevBtn.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const backToFirst = await counter.innerText();
|
||||
expect(backToFirst).toMatch(/1 \/ \d+/);
|
||||
});
|
||||
|
||||
test('2.5 tips with action buttons navigate to target', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Find and click through tips until we find one with an action button
|
||||
const nextBtn = page.getByRole('button', { name: /next tip/i });
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const actionBtn = page.locator('[role="status"] button').filter({ hasText: /scan|diagnos|check|view|open/i }).first();
|
||||
if (await actionBtn.isVisible({ timeout: 500 }).catch(() => false)) {
|
||||
const btnText = await actionBtn.innerText();
|
||||
await actionBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
// Should have navigated away from dashboard
|
||||
const url = page.url();
|
||||
expect(url).not.toBe('/');
|
||||
await snap(page, '02-action-button-navigated');
|
||||
return;
|
||||
}
|
||||
if (await nextBtn.isEnabled()) {
|
||||
await nextBtn.click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
// If no action button found in 7 tips, that's acceptable but notable
|
||||
});
|
||||
|
||||
test('2.6 close button dismisses the bubble', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const closeBtn = page.getByRole('button', { name: /close tip/i });
|
||||
await expect(closeBtn).toBeVisible();
|
||||
await closeBtn.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Bubble should be gone
|
||||
const bubble = page.locator('[role="status"]');
|
||||
await expect(bubble).not.toBeVisible();
|
||||
|
||||
// Mascot should still be visible
|
||||
await expect(mascot).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 3. SEARCH MODE — Type to search
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe('3 — Search Mode', () => {
|
||||
test('3.1 search input is visible in the bubble', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const searchInput = page.getByPlaceholder(/search|ask stella/i);
|
||||
await expect(searchInput).toBeVisible();
|
||||
});
|
||||
|
||||
test('3.2 typing a keyword switches to search mode', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const input = page.getByPlaceholder(/search|ask stella/i);
|
||||
await input.fill('SBOM vulnerability');
|
||||
await input.press('Enter');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Should show "Search" in the header or a back-to-tips button
|
||||
const searchHeader = page.locator('text=/Search/i').first();
|
||||
const backBtn = page.locator('text=/Tips/i').first();
|
||||
const isSearchMode = await searchHeader.isVisible({ timeout: 2000 }).catch(() => false)
|
||||
|| await backBtn.isVisible({ timeout: 2000 }).catch(() => false);
|
||||
expect(isSearchMode).toBe(true);
|
||||
await snap(page, '03-search-mode');
|
||||
});
|
||||
|
||||
test('3.3 back-to-tips button returns to tips mode', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const input = page.getByPlaceholder(/search|ask stella/i);
|
||||
await input.fill('policy');
|
||||
await input.press('Enter');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Click back to tips
|
||||
const backBtn = page.locator('button').filter({ hasText: /tips/i }).first();
|
||||
if (await backBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await backBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should show tip content again (not search)
|
||||
const counter = page.locator('text=/\\d+ \\/ \\d+/');
|
||||
await expect(counter).toBeVisible({ timeout: 3000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 4. CHAT MODE — Ask Stella questions
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe('4 — Chat Mode', () => {
|
||||
test('4.1 "Ask Stella" button switches to chat mode', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const askBtn = page.getByRole('button', { name: /ask stella/i });
|
||||
if (await askBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await askBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should show chat header
|
||||
const chatIndicator = page.locator('text=/Ask Stella/i').first();
|
||||
await expect(chatIndicator).toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Input placeholder should change
|
||||
const input = page.getByPlaceholder(/ask stella a question/i);
|
||||
await expect(input).toBeVisible();
|
||||
await snap(page, '04-chat-mode');
|
||||
}
|
||||
});
|
||||
|
||||
test('4.2 question in input auto-routes to chat mode', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const input = page.getByPlaceholder(/search|ask stella/i);
|
||||
await input.fill('How do I scan my first container image?');
|
||||
await input.press('Enter');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Questions should trigger chat mode (not search)
|
||||
const chatIndicator = page.locator('text=/Ask Stella/i').first();
|
||||
const isChat = await chatIndicator.isVisible({ timeout: 3000 }).catch(() => false);
|
||||
// Even if Advisory AI service is down, the mode should switch
|
||||
expect(isChat).toBe(true);
|
||||
await snap(page, '04-question-to-chat');
|
||||
});
|
||||
|
||||
test('4.3 chat shows error gracefully when AI service unavailable', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const askBtn = page.getByRole('button', { name: /ask stella/i });
|
||||
if (await askBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await askBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const input = page.getByPlaceholder(/ask stella/i);
|
||||
await input.fill('What is SBOM?');
|
||||
await input.press('Enter');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Should show either a response or a graceful error — NOT a blank screen
|
||||
const bubble = page.locator('[role="status"]');
|
||||
const bubbleText = await bubble.innerText();
|
||||
expect(bubbleText.length).toBeGreaterThan(10);
|
||||
await snap(page, '04-chat-error-graceful');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 5. MODE TRANSITIONS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe('5 — Mode Transitions', () => {
|
||||
test('5.1 tips → search → tips cycle works', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify tips mode (counter visible)
|
||||
const counter = page.locator('text=/\\d+ \\/ \\d+/');
|
||||
await expect(counter).toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Switch to search
|
||||
const input = page.getByPlaceholder(/search|ask stella/i);
|
||||
await input.fill('policy');
|
||||
await input.press('Enter');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Switch back to tips
|
||||
const backBtn = page.locator('button').filter({ hasText: /tips/i }).first();
|
||||
if (await backBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await backBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(counter).toBeVisible({ timeout: 3000 });
|
||||
}
|
||||
});
|
||||
|
||||
test('5.2 navigating to new page resets to tips mode', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Enter search mode
|
||||
const input = page.getByPlaceholder(/search|ask stella/i);
|
||||
await input.fill('test');
|
||||
await input.press('Enter');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Navigate to different page
|
||||
await go(page, '/security');
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should be back in tips mode with security-related content
|
||||
const tipText = await page.locator('[role="status"]').innerText();
|
||||
const securityTerms = ['security', 'posture', 'scan', 'vulnerability', 'risk'];
|
||||
const hasSecTerm = securityTerms.some(t => tipText.toLowerCase().includes(t));
|
||||
expect(hasSecTerm).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 6. DISMISS / RESTORE
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe('6 — Dismiss & Restore', () => {
|
||||
test('6.1 "Don\'t show again" hides the mascot', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const dismissBtn = page.getByRole('button', { name: /don.t show again/i });
|
||||
await expect(dismissBtn).toBeVisible();
|
||||
await dismissBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Full mascot should be hidden
|
||||
await expect(mascot).not.toBeVisible({ timeout: 2000 });
|
||||
await snap(page, '06-dismissed');
|
||||
});
|
||||
|
||||
test('6.2 restore button brings mascot back after dismiss', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Dismiss
|
||||
await page.getByRole('button', { name: /don.t show again/i }).click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Look for restore button (small icon with "?" badge)
|
||||
const restoreBtn = page.getByRole('button', { name: /show stella|bring back/i });
|
||||
await expect(restoreBtn).toBeVisible({ timeout: 3000 });
|
||||
await restoreBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Mascot should be back
|
||||
await expect(page.getByRole('button', { name: /stella helper/i })).toBeVisible();
|
||||
await snap(page, '06-restored');
|
||||
});
|
||||
|
||||
test('6.3 dismiss state persists across page reload', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.getByRole('button', { name: /don.t show again/i }).click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Reload page
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Mascot should still be dismissed (restore button visible instead)
|
||||
await expect(mascot).not.toBeVisible({ timeout: 3000 });
|
||||
const restoreBtn = page.getByRole('button', { name: /show stella|bring back/i });
|
||||
await expect(restoreBtn).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 7. PERSISTENCE ACROSS NAVIGATION
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe('7 — Persistence', () => {
|
||||
test('7.1 tip position is remembered per page', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Advance to tip 3
|
||||
const nextBtn = page.getByRole('button', { name: /next tip/i });
|
||||
await nextBtn.click();
|
||||
await page.waitForTimeout(200);
|
||||
await nextBtn.click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const counter = page.locator('text=/\\d+ \\/ \\d+/');
|
||||
const pos = await counter.innerText();
|
||||
expect(pos).toMatch(/3 \/ \d+/);
|
||||
|
||||
// Navigate away and back
|
||||
await go(page, '/security');
|
||||
await page.waitForTimeout(500);
|
||||
await go(page, '/');
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should return to tip 3
|
||||
const restoredPos = await counter.innerText();
|
||||
expect(restoredPos).toMatch(/3 \/ \d+/);
|
||||
});
|
||||
|
||||
test('7.2 first-visit auto-open only happens once per page', async ({ authenticatedPage: page }) => {
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem('stellaops.assistant.state');
|
||||
localStorage.removeItem('stellaops.helper.preferences');
|
||||
});
|
||||
|
||||
await go(page, '/');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// First visit: bubble auto-opens
|
||||
let bubble = page.locator('[role="status"]');
|
||||
const autoOpened = await bubble.isVisible({ timeout: 3000 }).catch(() => false);
|
||||
expect(autoOpened).toBe(true);
|
||||
|
||||
// Close it
|
||||
const closeBtn = page.getByRole('button', { name: /close tip/i });
|
||||
if (await closeBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await closeBtn.click();
|
||||
}
|
||||
|
||||
// Navigate away and back
|
||||
await go(page, '/security');
|
||||
await go(page, '/');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Second visit to same page: should NOT auto-open
|
||||
bubble = page.locator('[role="status"]');
|
||||
const autoOpenedAgain = await bubble.isVisible({ timeout: 2000 }).catch(() => false);
|
||||
expect(autoOpenedAgain).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 8. CONTEXTUAL TIP RELEVANCE (per page/tab)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe('8 — Contextual Relevance', () => {
|
||||
const pageExpectations: { route: string; name: string; expectedTerms: string[] }[] = [
|
||||
{ route: '/', name: 'Dashboard', expectedTerms: ['dashboard', 'overview', 'environment', 'sbom', 'feed'] },
|
||||
{ route: '/releases', name: 'Releases', expectedTerms: ['release', 'gate', 'evidence', 'promote', 'digest'] },
|
||||
{ route: '/releases/deployments', name: 'Deployments', expectedTerms: ['deployment', 'gate', 'approval', 'promote'] },
|
||||
{ route: '/security', name: 'Security Posture', expectedTerms: ['security', 'risk', 'posture', 'vex', 'sbom'] },
|
||||
{ route: '/security/reachability', name: 'Reachability', expectedTerms: ['reachability', 'call', 'static', 'runtime', 'code'] },
|
||||
{ route: '/ops/policy/vex', name: 'VEX', expectedTerms: ['vex', 'vulnerability', 'exploitability', 'statement', 'affected'] },
|
||||
{ route: '/ops/policy/governance', name: 'Governance', expectedTerms: ['budget', 'risk', 'threshold', 'policy'] },
|
||||
{ route: '/evidence/overview', name: 'Evidence', expectedTerms: ['evidence', 'signed', 'proof', 'audit', 'capsule'] },
|
||||
{ route: '/ops/operations', name: 'Operations', expectedTerms: ['operations', 'blocking', 'health', 'action'] },
|
||||
{ route: '/ops/operations/doctor', name: 'Diagnostics', expectedTerms: ['diagnostic', 'health', 'check', 'service'] },
|
||||
{ route: '/setup/integrations', name: 'Integrations', expectedTerms: ['integration', 'registry', 'connect', 'setup'] },
|
||||
{ route: '/setup/trust-signing', name: 'Certificates', expectedTerms: ['signing', 'key', 'certificate', 'trust', 'crypto'] },
|
||||
];
|
||||
|
||||
for (const { route, name, expectedTerms } of pageExpectations) {
|
||||
test(`8.x tips for ${name} (${route}) are relevant`, async ({ authenticatedPage: page }) => {
|
||||
await go(page, route);
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Collect all tip text (cycle through all tips)
|
||||
let allTipText = '';
|
||||
const counter = page.locator('text=/\\d+ \\/ \\d+/');
|
||||
const nextBtn = page.getByRole('button', { name: /next tip/i });
|
||||
|
||||
const bubble = page.locator('[role="status"]');
|
||||
allTipText += await bubble.innerText();
|
||||
|
||||
// Cycle through remaining tips
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (await nextBtn.isEnabled()) {
|
||||
await nextBtn.click();
|
||||
await page.waitForTimeout(200);
|
||||
allTipText += ' ' + await bubble.innerText();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// At least one expected term should appear in the combined tip text
|
||||
const lowerText = allTipText.toLowerCase();
|
||||
const matchedTerms = expectedTerms.filter(t => lowerText.includes(t));
|
||||
expect(
|
||||
matchedTerms.length,
|
||||
`Tips for ${name} should mention at least one of: [${expectedTerms.join(', ')}]. Got text: "${allTipText.slice(0, 200)}..."`
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 9. SEND BUTTON & INPUT BEHAVIOR
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe('9 — Input Behavior', () => {
|
||||
test('9.1 send button is disabled when input is empty', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const sendBtn = page.getByRole('button', { name: /send/i });
|
||||
await expect(sendBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
test('9.2 send button enables when text is entered', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const input = page.getByPlaceholder(/search|ask stella/i);
|
||||
await input.fill('test query');
|
||||
|
||||
const sendBtn = page.getByRole('button', { name: /send/i });
|
||||
await expect(sendBtn).toBeEnabled();
|
||||
});
|
||||
|
||||
test('9.3 Enter key submits the input', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const input = page.getByPlaceholder(/search|ask stella/i);
|
||||
await input.fill('SBOM');
|
||||
await input.press('Enter');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Should have transitioned away from tips mode
|
||||
// (either search mode header or chat mode header visible)
|
||||
const modeIndicator = page.locator('text=/Search|Ask Stella/i').first();
|
||||
await expect(modeIndicator).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 10. ACCESSIBILITY
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe('10 — Accessibility', () => {
|
||||
test('10.1 mascot has accessible role and label', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
|
||||
// The mascot container should have complementary role
|
||||
const assistant = page.getByRole('complementary', { name: /stella/i });
|
||||
await expect(assistant).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// The mascot button should have aria-label
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await expect(mascot).toBeVisible();
|
||||
});
|
||||
|
||||
test('10.2 bubble uses aria-live for screen readers', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Content area should use role="status" (implicit aria-live="polite")
|
||||
const liveRegion = page.locator('[role="status"]');
|
||||
await expect(liveRegion).toBeVisible();
|
||||
});
|
||||
|
||||
test('10.3 close and navigation buttons have accessible labels', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await expect(page.getByRole('button', { name: /close tip/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /previous tip/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /next tip/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('10.4 mascot button indicates expanded state', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
|
||||
// Before clicking — not expanded
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// After clicking — should have aria-expanded="true"
|
||||
const expanded = await mascot.getAttribute('aria-expanded');
|
||||
expect(expanded).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 11. MULTIPLE PAGES SMOKE — No crashes
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe('11 — Smoke: Assistant survives rapid navigation', () => {
|
||||
test('11.1 rapid navigation does not crash the assistant', async ({ authenticatedPage: page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error' && /NG0|TypeError|ReferenceError/.test(msg.text())) {
|
||||
errors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
const routes = [
|
||||
'/', '/releases', '/releases/deployments', '/environments/overview',
|
||||
'/security', '/security/reachability', '/ops/policy/vex',
|
||||
'/evidence/overview', '/ops/operations', '/setup/integrations',
|
||||
'/ops/policy/governance', '/ops/operations/doctor',
|
||||
];
|
||||
|
||||
for (const route of routes) {
|
||||
await page.goto(route, { waitUntil: 'domcontentloaded', timeout: 15_000 });
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
// Mascot should still be functional
|
||||
const mascot = page.getByRole('button', { name: /stella helper/i });
|
||||
await expect(mascot).toBeVisible();
|
||||
await mascot.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const bubble = page.locator('[role="status"]');
|
||||
await expect(bubble).toBeVisible();
|
||||
|
||||
// No critical Angular errors
|
||||
const criticalErrors = errors.filter(e => /NG0/.test(e));
|
||||
expect(criticalErrors, `Angular errors: ${criticalErrors.join('\n')}`).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 12. ADMIN EDITOR (requires ui.admin scope)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe('12 — Admin Editor', () => {
|
||||
test('12.1 admin page renders at /console-admin/assistant', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/console-admin/assistant');
|
||||
|
||||
const heading = page.locator('text=/Stella Assistant Editor/i');
|
||||
await expect(heading).toBeVisible({ timeout: 10_000 });
|
||||
await snap(page, '12-admin-editor');
|
||||
});
|
||||
|
||||
test('12.2 admin page has tabs for Tips and Glossary', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/console-admin/assistant');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const tipsTab = page.getByRole('tab', { name: /tips/i });
|
||||
const glossaryTab = page.getByRole('tab', { name: /glossary/i });
|
||||
|
||||
await expect(tipsTab).toBeVisible();
|
||||
await expect(glossaryTab).toBeVisible();
|
||||
});
|
||||
|
||||
test('12.3 new tip form has preview', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/console-admin/assistant');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const newTipTab = page.getByRole('tab', { name: /new tip/i });
|
||||
await newTipTab.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Form fields should be visible
|
||||
const routeInput = page.getByPlaceholder(/\/ops\/policy/i);
|
||||
await expect(routeInput).toBeVisible();
|
||||
|
||||
// Preview section should exist
|
||||
const preview = page.locator('text=/preview/i').first();
|
||||
await expect(preview).toBeVisible();
|
||||
await snap(page, '12-admin-new-tip-form');
|
||||
});
|
||||
});
|
||||
@@ -122,6 +122,7 @@ export const SEVERITY_COLORS: Record<SearchResultSeverity, string> = {
|
||||
};
|
||||
|
||||
export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
|
||||
// ── Top 5 (shown by default when palette opens) ──────────────────
|
||||
{
|
||||
id: 'scan',
|
||||
label: 'Scan Artifact',
|
||||
@@ -129,34 +130,7 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
|
||||
description: 'Opens artifact scan dialog',
|
||||
icon: 'scan',
|
||||
route: '/security/scan',
|
||||
keywords: ['scan', 'artifact', 'analyze'],
|
||||
},
|
||||
{
|
||||
id: 'vex',
|
||||
label: 'Create VEX Statement',
|
||||
shortcut: '>vex',
|
||||
description: 'Open VEX creation workflow',
|
||||
icon: 'shield-check',
|
||||
route: '/security/advisories-vex',
|
||||
keywords: ['vex', 'create', 'statement'],
|
||||
},
|
||||
{
|
||||
id: 'policy',
|
||||
label: 'New Policy Pack',
|
||||
shortcut: '>policy',
|
||||
description: 'Create new policy pack',
|
||||
icon: 'shield',
|
||||
route: '/ops/policy/packs',
|
||||
keywords: ['policy', 'new', 'pack', 'create'],
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
label: 'View Jobs',
|
||||
shortcut: '>jobs',
|
||||
description: 'Navigate to job list',
|
||||
icon: 'workflow',
|
||||
route: '/ops/operations/jobs-queues',
|
||||
keywords: ['jobs', 'jobengine', 'list'],
|
||||
keywords: ['scan', 'artifact', 'analyze', 'image', 'container', 'vulnerability'],
|
||||
},
|
||||
{
|
||||
id: 'findings',
|
||||
@@ -165,25 +139,62 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
|
||||
description: 'Navigate to findings list',
|
||||
icon: 'alert-triangle',
|
||||
route: '/security/triage',
|
||||
keywords: ['findings', 'vulnerabilities', 'list'],
|
||||
keywords: ['findings', 'vulnerabilities', 'list', 'triage', 'security'],
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Go to Settings',
|
||||
shortcut: '>settings',
|
||||
description: 'Navigate to settings',
|
||||
icon: 'settings',
|
||||
route: '/setup',
|
||||
keywords: ['settings', 'config', 'preferences'],
|
||||
id: 'pending-approvals',
|
||||
label: 'Pending Approvals',
|
||||
shortcut: '>approvals',
|
||||
description: 'View pending release approvals',
|
||||
icon: 'check-circle',
|
||||
route: '/releases/approvals',
|
||||
keywords: ['approvals', 'pending', 'gate', 'release', 'review', 'decision'],
|
||||
},
|
||||
{
|
||||
id: 'health',
|
||||
label: 'Platform Health',
|
||||
shortcut: '>health',
|
||||
description: 'View platform health status',
|
||||
icon: 'heart-pulse',
|
||||
route: '/ops/operations/system-health',
|
||||
keywords: ['health', 'status', 'platform', 'ops', 'doctor', 'system'],
|
||||
id: 'security-posture',
|
||||
label: 'Security Posture',
|
||||
shortcut: '>posture',
|
||||
description: 'View security posture overview',
|
||||
icon: 'shield',
|
||||
route: '/security',
|
||||
keywords: ['security', 'posture', 'risk', 'dashboard', 'overview'],
|
||||
},
|
||||
{
|
||||
id: 'create-release',
|
||||
label: 'Create Release',
|
||||
shortcut: '>release',
|
||||
description: 'Create a new release version',
|
||||
icon: 'package',
|
||||
route: '/releases/versions/new',
|
||||
keywords: ['create', 'release', 'version', 'new', 'deploy'],
|
||||
},
|
||||
// ── Remaining actions (alphabetical by id) ───────────────────────
|
||||
{
|
||||
id: 'check-policy-gates',
|
||||
label: 'Check Policy Gates',
|
||||
shortcut: '>gates',
|
||||
description: 'Review policy gate status and results',
|
||||
icon: 'shield',
|
||||
route: '/ops/policy/gates',
|
||||
keywords: ['policy', 'gates', 'check', 'governance', 'compliance'],
|
||||
},
|
||||
{
|
||||
id: 'configure-advisory-sources',
|
||||
label: 'Configure Advisory Sources',
|
||||
shortcut: '>advisory',
|
||||
description: 'Manage advisory and VEX data sources',
|
||||
icon: 'plug',
|
||||
route: '/setup/integrations/advisory-vex-sources',
|
||||
keywords: ['advisory', 'sources', 'vex', 'configure', 'integrations', 'feed'],
|
||||
},
|
||||
{
|
||||
id: 'create-exception',
|
||||
label: 'Create Exception',
|
||||
shortcut: '>exception',
|
||||
description: 'Create a new policy exception or waiver',
|
||||
icon: 'file-text',
|
||||
route: '/exceptions',
|
||||
keywords: ['create', 'exception', 'waiver', 'exemption', 'new'],
|
||||
},
|
||||
{
|
||||
id: 'doctor-quick',
|
||||
@@ -199,7 +210,43 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
|
||||
shortcut: '>diagnostics',
|
||||
description: 'Run comprehensive Doctor diagnostics',
|
||||
icon: 'search',
|
||||
keywords: ['doctor', 'diagnostics', 'full', 'comprehensive'],
|
||||
keywords: ['doctor', 'diagnostics', 'full', 'comprehensive', 'diag'],
|
||||
},
|
||||
{
|
||||
id: 'environments',
|
||||
label: 'Environments',
|
||||
shortcut: '>envs',
|
||||
description: 'View environment topology and targets',
|
||||
icon: 'globe',
|
||||
route: '/environments/overview',
|
||||
keywords: ['environments', 'topology', 'regions', 'overview', 'targets', 'envs'],
|
||||
},
|
||||
{
|
||||
id: 'evidence-export',
|
||||
label: 'Export Evidence',
|
||||
shortcut: '>export',
|
||||
description: 'Export evidence bundles for compliance',
|
||||
icon: 'download',
|
||||
route: '/evidence/exports',
|
||||
keywords: ['evidence', 'export', 'bundle', 'compliance', 'report'],
|
||||
},
|
||||
{
|
||||
id: 'exception-queue',
|
||||
label: 'Exception Queue',
|
||||
shortcut: '>exceptions',
|
||||
description: 'View and manage policy exceptions',
|
||||
icon: 'file-text',
|
||||
route: '/ops/policy/vex/exceptions',
|
||||
keywords: ['exceptions', 'queue', 'waiver', 'policy', 'exemption'],
|
||||
},
|
||||
{
|
||||
id: 'health',
|
||||
label: 'Platform Health',
|
||||
shortcut: '>health',
|
||||
description: 'View platform health status',
|
||||
icon: 'heart-pulse',
|
||||
route: '/ops/operations/system-health',
|
||||
keywords: ['health', 'status', 'platform', 'ops', 'doctor', 'system'],
|
||||
},
|
||||
{
|
||||
id: 'integrations',
|
||||
@@ -210,6 +257,42 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
|
||||
route: '/ops/integrations',
|
||||
keywords: ['integrations', 'connect', 'manage'],
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
label: 'View Jobs',
|
||||
shortcut: '>jobs',
|
||||
description: 'Navigate to job list',
|
||||
icon: 'workflow',
|
||||
route: '/ops/operations/jobs-queues',
|
||||
keywords: ['jobs', 'jobengine', 'list', 'queue'],
|
||||
},
|
||||
{
|
||||
id: 'policy',
|
||||
label: 'New Policy Pack',
|
||||
shortcut: '>policy',
|
||||
description: 'Create new policy pack',
|
||||
icon: 'shield',
|
||||
route: '/ops/policy/packs',
|
||||
keywords: ['policy', 'new', 'pack', 'create'],
|
||||
},
|
||||
{
|
||||
id: 'reachability',
|
||||
label: 'Reachability Analysis',
|
||||
shortcut: '>reach',
|
||||
description: 'View reachability analysis and exploitability',
|
||||
icon: 'cpu',
|
||||
route: '/security/reachability',
|
||||
keywords: ['reachability', 'analysis', 'exploitable', 'path', 'reach'],
|
||||
},
|
||||
{
|
||||
id: 'risk-budget',
|
||||
label: 'Risk Budget',
|
||||
shortcut: '>budget',
|
||||
description: 'View risk budget and governance dashboard',
|
||||
icon: 'shield',
|
||||
route: '/ops/policy/governance',
|
||||
keywords: ['risk', 'budget', 'governance', 'threshold', 'consumption'],
|
||||
},
|
||||
{
|
||||
id: 'seed-demo',
|
||||
label: 'Seed Demo Data',
|
||||
@@ -219,49 +302,22 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
|
||||
keywords: ['seed', 'demo', 'data', 'populate', 'sample', 'mock'],
|
||||
},
|
||||
{
|
||||
id: 'scan-image',
|
||||
label: 'Scan Image',
|
||||
shortcut: '>scan-image',
|
||||
description: 'Scan a container image for vulnerabilities',
|
||||
icon: 'scan',
|
||||
route: '/security/scan',
|
||||
keywords: ['scan', 'image', 'container', 'vulnerability'],
|
||||
id: 'settings',
|
||||
label: 'Go to Settings',
|
||||
shortcut: '>settings',
|
||||
description: 'Navigate to settings',
|
||||
icon: 'settings',
|
||||
route: '/setup',
|
||||
keywords: ['settings', 'config', 'preferences'],
|
||||
},
|
||||
{
|
||||
id: 'view-vulnerabilities',
|
||||
label: 'View Vulnerabilities',
|
||||
shortcut: '>vulns',
|
||||
description: 'Browse vulnerability triage queue',
|
||||
icon: 'alert-triangle',
|
||||
route: '/triage/artifacts',
|
||||
keywords: ['vulnerabilities', 'vulns', 'triage', 'cve', 'security'],
|
||||
},
|
||||
{
|
||||
id: 'search-cve',
|
||||
label: 'Search CVE',
|
||||
shortcut: '>cve',
|
||||
description: 'Search for a specific CVE in triage artifacts',
|
||||
icon: 'search',
|
||||
route: '/triage/artifacts',
|
||||
keywords: ['cve', 'search', 'vulnerability', 'advisory'],
|
||||
},
|
||||
{
|
||||
id: 'view-findings',
|
||||
label: 'View Findings',
|
||||
shortcut: '>view-findings',
|
||||
description: 'Navigate to security findings list',
|
||||
icon: 'alert-triangle',
|
||||
route: '/triage/artifacts',
|
||||
keywords: ['findings', 'vulnerabilities', 'security', 'list'],
|
||||
},
|
||||
{
|
||||
id: 'create-release',
|
||||
label: 'Create Release',
|
||||
shortcut: '>release',
|
||||
description: 'Create a new release version',
|
||||
icon: 'package',
|
||||
route: '/releases/versions/new',
|
||||
keywords: ['create', 'release', 'version', 'new', 'deploy'],
|
||||
id: 'vex',
|
||||
label: 'Create VEX Statement',
|
||||
shortcut: '>vex',
|
||||
description: 'Open VEX creation workflow',
|
||||
icon: 'shield-check',
|
||||
route: '/security/advisories-vex',
|
||||
keywords: ['vex', 'create', 'statement'],
|
||||
},
|
||||
{
|
||||
id: 'view-audit-log',
|
||||
@@ -272,24 +328,6 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
|
||||
route: '/evidence/audit-log',
|
||||
keywords: ['audit', 'log', 'evidence', 'history', 'trail'],
|
||||
},
|
||||
{
|
||||
id: 'run-diagnostics',
|
||||
label: 'Run Diagnostics',
|
||||
shortcut: '>diag',
|
||||
description: 'Open the Doctor diagnostics dashboard',
|
||||
icon: 'activity',
|
||||
route: '/ops/operations/doctor',
|
||||
keywords: ['diagnostics', 'doctor', 'health', 'check', 'run'],
|
||||
},
|
||||
{
|
||||
id: 'configure-advisory-sources',
|
||||
label: 'Configure Advisory Sources',
|
||||
shortcut: '>advisory',
|
||||
description: 'Manage advisory and VEX data sources',
|
||||
icon: 'plug',
|
||||
route: '/setup/integrations/advisory-vex-sources',
|
||||
keywords: ['advisory', 'sources', 'vex', 'configure', 'integrations', 'feed'],
|
||||
},
|
||||
{
|
||||
id: 'view-promotions',
|
||||
label: 'View Promotions',
|
||||
@@ -300,13 +338,13 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
|
||||
keywords: ['promotions', 'promote', 'environment', 'deploy', 'release'],
|
||||
},
|
||||
{
|
||||
id: 'check-policy-gates',
|
||||
label: 'Check Policy Gates',
|
||||
shortcut: '>gates',
|
||||
description: 'Review policy gate status and results',
|
||||
icon: 'shield',
|
||||
route: '/ops/policy/gates',
|
||||
keywords: ['policy', 'gates', 'check', 'governance', 'compliance'],
|
||||
id: 'view-vulnerabilities',
|
||||
label: 'Browse Vulnerabilities',
|
||||
shortcut: '>vulns',
|
||||
description: 'Browse vulnerability triage queue',
|
||||
icon: 'alert-triangle',
|
||||
route: '/triage/artifacts',
|
||||
keywords: ['vulnerabilities', 'vulns', 'triage', 'cve', 'security', 'search', 'advisory'],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -45,47 +45,7 @@ import {
|
||||
imports: [FormsModule, ChatMessageComponent],
|
||||
template: `
|
||||
<div class="chat-container" [class.loading]="isLoading()">
|
||||
<!-- Header -->
|
||||
<header class="chat-header">
|
||||
<div class="header-left">
|
||||
<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">Search assistant</h2>
|
||||
@if (conversation()) {
|
||||
<span class="conversation-id">{{ conversation()!.conversationId.substring(0, 8) }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
@if (conversation()) {
|
||||
<button
|
||||
type="button"
|
||||
class="header-btn"
|
||||
title="New conversation"
|
||||
(click)="startNewConversation()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="header-btn close-btn"
|
||||
title="Close chat"
|
||||
(click)="close.emit()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Header removed — the host's title bar provides all controls -->
|
||||
|
||||
<!-- Messages container -->
|
||||
<div class="chat-messages" #messagesContainer>
|
||||
@@ -174,6 +134,7 @@ import {
|
||||
[placeholder]="inputPlaceholder()"
|
||||
[disabled]="isStreaming()"
|
||||
[(ngModel)]="inputValue"
|
||||
(ngModelChange)="onChatInput()"
|
||||
(keydown)="handleKeydown($event)"
|
||||
rows="1"></textarea>
|
||||
<button
|
||||
@@ -505,8 +466,8 @@ import {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
background: var(--color-brand-primary, #F5A623);
|
||||
color: var(--color-text-heading, #2A1E00);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
@@ -517,7 +478,7 @@ import {
|
||||
}
|
||||
|
||||
.send-btn:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
background: color-mix(in srgb, var(--color-brand-primary, #F5A623) 85%, black);
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
@@ -554,6 +515,9 @@ export class ChatComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
@Output() close = new EventEmitter<void>();
|
||||
@Output() expandChange = new EventEmitter<boolean>();
|
||||
@Output() inputTyping = new EventEmitter<string>();
|
||||
@Output() conversationCreated = new EventEmitter<string>();
|
||||
@Output() linkNavigate = new EventEmitter<ParsedObjectLink>();
|
||||
@Output() actionExecute = new EventEmitter<{ action: ProposedAction; turnId: string }>();
|
||||
@Output() searchForMore = new EventEmitter<string>();
|
||||
@@ -569,6 +533,7 @@ export class ChatComponent implements OnInit, OnDestroy {
|
||||
|
||||
inputValue = '';
|
||||
readonly progressStage = signal<string | null>(null);
|
||||
readonly expanded = signal(false);
|
||||
|
||||
// Expose service signals
|
||||
readonly conversation = this.chatService.conversation;
|
||||
@@ -577,9 +542,9 @@ export class ChatComponent implements OnInit, OnDestroy {
|
||||
readonly streamingContent = this.chatService.streamingContent;
|
||||
readonly error = this.chatService.error;
|
||||
|
||||
readonly canSend = computed(() => {
|
||||
canSend(): boolean {
|
||||
return this.inputValue.trim().length > 0 && !this.isStreaming();
|
||||
});
|
||||
}
|
||||
|
||||
readonly inputPlaceholder = computed(() => {
|
||||
if (this.isStreaming()) {
|
||||
@@ -636,7 +601,8 @@ export class ChatComponent implements OnInit, OnDestroy {
|
||||
this.trySendPendingInitialMessage();
|
||||
});
|
||||
} else {
|
||||
this.chatService.createConversation(this.tenantId, this.context).subscribe(() => {
|
||||
this.chatService.createConversation(this.tenantId, this.context).subscribe((conv) => {
|
||||
this.conversationCreated.emit(conv.conversationId);
|
||||
this.trySendPendingInitialMessage();
|
||||
});
|
||||
}
|
||||
@@ -656,6 +622,13 @@ export class ChatComponent implements OnInit, OnDestroy {
|
||||
const conversation = this.conversation();
|
||||
if (conversation) {
|
||||
this.chatService.sendMessage(conversation.conversationId, message);
|
||||
} else {
|
||||
// No conversation yet — create one first, then send
|
||||
this.chatService.createConversation(this.tenantId, this.context).subscribe({
|
||||
next: (conv) => {
|
||||
this.chatService.sendMessage(conv.conversationId, message);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,6 +644,15 @@ export class ChatComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
onChatInput(): void {
|
||||
this.inputTyping.emit(this.inputValue);
|
||||
}
|
||||
|
||||
toggleExpand(): void {
|
||||
this.expanded.update(v => !v);
|
||||
this.expandChange.emit(this.expanded());
|
||||
}
|
||||
|
||||
startNewConversation(): void {
|
||||
this.chatService.clearConversation();
|
||||
this.chatService.createConversation(this.tenantId, this.context).subscribe(() => {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Injectable, inject, signal, computed } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, Subject, BehaviorSubject, catchError, tap, finalize, of, from } from 'rxjs';
|
||||
import { AuthorityAuthService } from '../../../core/auth/authority-auth.service';
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContext,
|
||||
@@ -37,6 +38,7 @@ const API_BASE = '/api/v1/advisory-ai';
|
||||
})
|
||||
export class ChatService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly auth = inject(AuthorityAuthService);
|
||||
|
||||
// Reactive state
|
||||
private readonly _state = signal<ChatState>({
|
||||
@@ -176,34 +178,65 @@ export class ChatService {
|
||||
* Starts SSE stream for a message.
|
||||
*/
|
||||
private startStream(conversationId: string, message: string): void {
|
||||
const url = `${API_BASE}/conversations/${conversationId}/turns`;
|
||||
// Use the legacy conversation turns endpoint routed via Valkey RPC.
|
||||
// The gateway maps /api/v1/advisory-ai/* to the backend via Valkey.
|
||||
// HandleAddTurn returns JSON (not SSE), so we simulate streaming client-side.
|
||||
this.http.post<any>(`${API_BASE}/conversations/${conversationId}/turns`, {
|
||||
content: message,
|
||||
}).subscribe({
|
||||
next: (response) => {
|
||||
// Simulate streaming: emit tokens word by word
|
||||
const content = response?.response ?? response?.content ?? response?.answer ?? JSON.stringify(response);
|
||||
const words = content.split(' ');
|
||||
let accumulated = '';
|
||||
|
||||
// Use fetch for SSE since HttpClient doesn't support streaming well
|
||||
const abortController = new AbortController();
|
||||
this.streamEvents$.next({ event: 'start', data: {} } as any);
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/event-stream',
|
||||
let i = 0;
|
||||
const interval = setInterval(() => {
|
||||
if (i < words.length) {
|
||||
accumulated += words[i] + ' ';
|
||||
this.updateState({ streamingContent: accumulated });
|
||||
this.streamEvents$.next({ event: 'token', data: { content: words[i] + ' ' } } as any);
|
||||
i++;
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
|
||||
// Build the assistant turn
|
||||
const conversation = this._state().conversation;
|
||||
if (conversation) {
|
||||
const assistantTurn: ConversationTurn = {
|
||||
turnId: `resp-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: accumulated.trim(),
|
||||
timestamp: new Date().toISOString(),
|
||||
citations: response?.citations ?? [],
|
||||
proposedActions: response?.proposedActions ?? [],
|
||||
groundingScore: response?.groundingScore,
|
||||
};
|
||||
|
||||
const updated = {
|
||||
...conversation,
|
||||
turns: [...conversation.turns, assistantTurn],
|
||||
};
|
||||
|
||||
this.updateState({
|
||||
conversation: updated,
|
||||
isStreaming: false,
|
||||
streamingContent: '',
|
||||
});
|
||||
} else {
|
||||
this.updateState({ isStreaming: false, streamingContent: '' });
|
||||
}
|
||||
|
||||
this.streamEvents$.next({ event: 'done', data: { turnId: `resp-${Date.now()}` } } as any);
|
||||
}
|
||||
}, 40);
|
||||
},
|
||||
body: JSON.stringify({ content: message }),
|
||||
signal: abortController.signal,
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
return this.processStream(response.body.getReader());
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name !== 'AbortError') {
|
||||
this.handleStreamError(err);
|
||||
}
|
||||
});
|
||||
error: (err) => {
|
||||
this.handleStreamError(new Error(err.message || 'Failed to get response'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +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 { GlobalSearchComponent } from '../global-search/global-search.component';
|
||||
// GlobalSearchComponent removed — search is now handled by the Stella mascot
|
||||
import { ContextChipsComponent } from '../context-chips/context-chips.component';
|
||||
import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.component';
|
||||
import { I18nService, UserLocalePreferenceService } from '../../core/i18n';
|
||||
@@ -44,7 +44,6 @@ import { ContentWidthService } from '../../core/services/content-width.service';
|
||||
selector: 'app-topbar',
|
||||
standalone: true,
|
||||
imports: [
|
||||
GlobalSearchComponent,
|
||||
ContextChipsComponent,
|
||||
UserMenuComponent,
|
||||
OfflineStatusChipComponent,
|
||||
@@ -57,8 +56,8 @@ import { ContentWidthService } from '../../core/services/content-width.service';
|
||||
],
|
||||
template: `
|
||||
<header class="topbar" role="banner">
|
||||
<!-- Row 1: Logo + Search + Create + User -->
|
||||
<div class="topbar__row topbar__row--primary">
|
||||
<!-- Single merged row: status chips → tenant → filters → user menu -->
|
||||
<div class="topbar__row">
|
||||
<!-- Mobile menu toggle -->
|
||||
<button
|
||||
type="button"
|
||||
@@ -73,35 +72,17 @@ import { ContentWidthService } from '../../core/services/content-width.service';
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Global Search -->
|
||||
<div class="topbar__search">
|
||||
<app-global-search></app-global-search>
|
||||
</div>
|
||||
<!-- Status chips (leading position) -->
|
||||
@if (isAuthenticated()) {
|
||||
<div class="topbar__status-chips">
|
||||
<app-live-event-stream-chip></app-live-event-stream-chip>
|
||||
<app-policy-baseline-chip></app-policy-baseline-chip>
|
||||
<app-evidence-mode-chip></app-evidence-mode-chip>
|
||||
<app-feed-snapshot-chip></app-feed-snapshot-chip>
|
||||
<app-offline-status-chip></app-offline-status-chip>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Right section: User -->
|
||||
<div class="topbar__right">
|
||||
<button
|
||||
type="button"
|
||||
class="topbar__context-toggle"
|
||||
[class.topbar__context-toggle--active]="mobileContextOpen()"
|
||||
[attr.aria-expanded]="mobileContextOpen()"
|
||||
aria-controls="topbar-context-row"
|
||||
(click)="toggleMobileContext()"
|
||||
>
|
||||
Context
|
||||
</button>
|
||||
|
||||
<!-- User menu -->
|
||||
<app-user-menu></app-user-menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Tenant + Status chips + Context controls -->
|
||||
<div
|
||||
id="topbar-context-row"
|
||||
class="topbar__row topbar__row--secondary"
|
||||
[class.topbar__row--secondary-open]="mobileContextOpen()"
|
||||
>
|
||||
<!-- Tenant (always shown when authenticated) -->
|
||||
@if (isAuthenticated()) {
|
||||
<div class="topbar__tenant">
|
||||
@@ -204,16 +185,19 @@ import { ContentWidthService } from '../../core/services/content-width.service';
|
||||
<stella-view-mode-switcher></stella-view-mode-switcher>
|
||||
}
|
||||
|
||||
@if (isAuthenticated()) {
|
||||
<!-- Status indicator chips -->
|
||||
<div class="topbar__status-chips">
|
||||
<app-live-event-stream-chip></app-live-event-stream-chip>
|
||||
<app-policy-baseline-chip></app-policy-baseline-chip>
|
||||
<app-evidence-mode-chip></app-evidence-mode-chip>
|
||||
<app-feed-snapshot-chip></app-feed-snapshot-chip>
|
||||
<app-offline-status-chip></app-offline-status-chip>
|
||||
</div>
|
||||
}
|
||||
<!-- Right: mobile context toggle + user menu -->
|
||||
<div class="topbar__right">
|
||||
<button
|
||||
type="button"
|
||||
class="topbar__context-toggle"
|
||||
[class.topbar__context-toggle--active]="mobileContextOpen()"
|
||||
[attr.aria-expanded]="mobileContextOpen()"
|
||||
(click)="toggleMobileContext()"
|
||||
>
|
||||
Context
|
||||
</button>
|
||||
<app-user-menu></app-user-menu>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
`,
|
||||
@@ -221,46 +205,30 @@ import { ContentWidthService } from '../../core/services/content-width.service';
|
||||
/* ---- Shell ---- */
|
||||
.topbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-header-bg);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border-primary) 35%, transparent);
|
||||
}
|
||||
|
||||
/* ---- Row layout ---- */
|
||||
/* ---- Single merged row ---- */
|
||||
.topbar__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.topbar__row--primary {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.topbar__row--secondary {
|
||||
height: 32px;
|
||||
gap: 0.5rem;
|
||||
padding: 0 1rem;
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.topbar__row {
|
||||
gap: 0.375rem;
|
||||
gap: 0.25rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.topbar__row--secondary {
|
||||
padding: 0 0.5rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar__row--secondary-open {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
padding: 0.35rem 0.5rem;
|
||||
overflow: visible;
|
||||
min-height: 36px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,20 +261,7 @@ import { ContentWidthService } from '../../core/services/content-width.service';
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Search ---- */
|
||||
.topbar__search {
|
||||
flex: 1;
|
||||
max-width: 540px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.topbar__search {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Right section (row 1) ---- */
|
||||
/* ---- Right section ---- */
|
||||
.topbar__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -524,13 +479,13 @@ import { ContentWidthService } from '../../core/services/content-width.service';
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Status chips (right-aligned) ---- */
|
||||
/* ---- Status chips (leading position) ---- */
|
||||
.topbar__status-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: nowrap;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- Content width toggle ---- */
|
||||
|
||||
@@ -33,6 +33,7 @@ import { SynthesisPanelComponent } from '../../shared/components/synthesis-panel
|
||||
import { AmbientContextService } from '../../core/services/ambient-context.service';
|
||||
import { SearchChatContextService } from '../../core/services/search-chat-context.service';
|
||||
import { SearchAssistantDrawerService } from '../../core/services/search-assistant-drawer.service';
|
||||
import { StellaAssistantService } from '../../shared/components/stella-helper/stella-assistant.service';
|
||||
import { I18nService } from '../../core/i18n';
|
||||
import { TelemetryClient } from '../../core/telemetry/telemetry.client';
|
||||
import { normalizeSearchActionRoute } from './search-route-matrix';
|
||||
@@ -975,6 +976,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
private readonly ambientContext = inject(AmbientContextService);
|
||||
private readonly searchChatContext = inject(SearchChatContextService);
|
||||
private readonly assistantDrawer = inject(SearchAssistantDrawerService);
|
||||
private readonly stellaAssistant = inject(StellaAssistantService);
|
||||
private readonly i18n = inject(I18nService);
|
||||
private readonly telemetry = inject(TelemetryClient);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
@@ -2321,7 +2323,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
this.closeResults();
|
||||
this.assistantDrawer.open({
|
||||
initialUserMessage: suggestedPrompt,
|
||||
source: 'global_search_answer',
|
||||
source: 'global_search',
|
||||
fallbackFocusTarget: this.searchInputRef?.nativeElement ?? null,
|
||||
});
|
||||
}
|
||||
@@ -2432,7 +2434,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
this.closeResults();
|
||||
this.assistantDrawer.open({
|
||||
initialUserMessage: suggestedPrompt,
|
||||
source: 'global_search_entry',
|
||||
source: 'global_search',
|
||||
fallbackFocusTarget: this.searchInputRef?.nativeElement ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,114 +6,469 @@ import {
|
||||
ViewChild,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
} from '@angular/core';
|
||||
|
||||
import { Router } from '@angular/router';
|
||||
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||
import { SearchAssistantDrawerService } from '../../core/services/search-assistant-drawer.service';
|
||||
import { StellaAssistantService } from '../../shared/components/stella-helper/stella-assistant.service';
|
||||
import { ChatComponent } from '../../features/advisory-ai/chat';
|
||||
|
||||
interface TrailBubble { x: number; y: number; size: number; delay: number; }
|
||||
|
||||
@Component({
|
||||
selector: 'app-search-assistant-host',
|
||||
standalone: true,
|
||||
imports: [ChatComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
@if (assistantDrawer.isOpen()) {
|
||||
@if (assistantDrawer.isOpen() || exiting()) {
|
||||
<div
|
||||
class="assistant-host__backdrop"
|
||||
class="ah__backdrop"
|
||||
[class.ah--entering]="entering()"
|
||||
[class.ah--exiting]="exiting()"
|
||||
(click)="onBackdropClick($event)"
|
||||
>
|
||||
<section
|
||||
#assistantDrawerElement
|
||||
class="assistant-host__drawer assistant-drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search assistant"
|
||||
tabindex="-1"
|
||||
<!-- Bubble trail -->
|
||||
<div class="ah__trail"
|
||||
[class.ah__trail--streaming]="stella.chatAnimationState() === 'thinking' || stella.chatAnimationState() === 'typing' || stella.searchLoading()"
|
||||
aria-hidden="true">
|
||||
@for (b of trailBubbles(); track b.delay) {
|
||||
<span class="ah__bubble"
|
||||
[style.left.px]="b.x - b.size / 2"
|
||||
[style.top.px]="b.y - b.size / 2"
|
||||
[style.width.px]="b.size"
|
||||
[style.height.px]="b.size"
|
||||
[style.animation-delay]="b.delay + 'ms'"
|
||||
></span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<section #drawerEl
|
||||
class="ah__drawer"
|
||||
[class.ah__drawer--expanded]="drawerExpanded()"
|
||||
role="dialog" aria-modal="true" aria-label="Asking Stella" tabindex="-1"
|
||||
>
|
||||
<stellaops-chat
|
||||
[tenantId]="context.tenantId() ?? 'default'"
|
||||
[initialUserMessage]="assistantDrawer.initialUserMessage()"
|
||||
(close)="close()"
|
||||
(searchForMore)="onSearchForMore($event)"
|
||||
/>
|
||||
<!-- ═══ Title Bar ═══ -->
|
||||
<header class="ah__titlebar">
|
||||
<div class="ah__titlebar-left">
|
||||
<img src="assets/img/stella-ops-icon.png" alt="" width="22" height="22" class="ah__titlebar-icon" />
|
||||
<h2 class="ah__titlebar-text">Asking Stella...</h2>
|
||||
</div>
|
||||
<div class="ah__titlebar-right">
|
||||
<!-- View toggle -->
|
||||
<button class="ah__toggle-btn" (click)="onToggleView()"
|
||||
[title]="stella.drawerView() === 'chat' ? 'Search Stella' : 'Ask Stella'">
|
||||
@if (stella.drawerView() === 'chat') {
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="16.5" y1="16.5" x2="21" y2="21"/></svg>
|
||||
<span>Search Stella</span>
|
||||
} @else {
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><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>
|
||||
<span>Ask Stella</span>
|
||||
}
|
||||
</button>
|
||||
<!-- New Session -->
|
||||
<button class="ah__hdr-btn" title="New Session" (click)="onNewSession()">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
<!-- Maximize -->
|
||||
<button class="ah__hdr-btn" [title]="drawerExpanded() ? 'Collapse' : 'Maximize'" (click)="onToggleExpand()">
|
||||
@if (drawerExpanded()) {
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
||||
} @else {
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
||||
}
|
||||
</button>
|
||||
<!-- Close -->
|
||||
<button class="ah__hdr-btn ah__close-btn" title="Close" (click)="close()">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ═══ Content: single view at a time ═══ -->
|
||||
<div class="ah__content">
|
||||
@if (stella.drawerView() === 'chat') {
|
||||
@if (chatVisible()) {
|
||||
<stellaops-chat
|
||||
[tenantId]="context.tenantId() ?? 'default'"
|
||||
[initialUserMessage]="assistantDrawer.initialUserMessage()"
|
||||
[conversationId]="stella.activeConversationId() ?? undefined"
|
||||
(conversationCreated)="onConversationCreated($event)"
|
||||
(inputTyping)="onChatTyping($event)"
|
||||
(searchForMore)="onSearchForMore($event)"
|
||||
/>
|
||||
}
|
||||
} @else {
|
||||
<!-- Full-panel search view -->
|
||||
<div class="ah__search-view">
|
||||
<div class="ah__search-bar">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="16.5" y1="16.5" x2="21" y2="21"/></svg>
|
||||
<input class="ah__search-input"
|
||||
type="text" placeholder="Search Stella..."
|
||||
[value]="searchViewQuery()"
|
||||
(input)="onSearchViewInput($event)" />
|
||||
@if (stella.searchLoading()) {
|
||||
<span class="ah__spinner"></span>
|
||||
}
|
||||
</div>
|
||||
<div class="ah__search-body">
|
||||
@if (stella.searchSynthesis(); as syn) {
|
||||
<div class="ah__syn">{{ syn.summary }}</div>
|
||||
}
|
||||
@if (stella.searchResults().length > 0) {
|
||||
@for (card of stella.searchResults(); track card.entityKey) {
|
||||
<a class="ah__card" [href]="getCardRoute(card)" (click)="onCardClick($event, card)">
|
||||
<span class="ah__card-type">{{ card.entityType }}</span>
|
||||
<span class="ah__card-title">{{ card.title }}</span>
|
||||
<span class="ah__card-snip">{{ card.snippet }}</span>
|
||||
</a>
|
||||
}
|
||||
} @else if (!stella.searchLoading()) {
|
||||
<div class="ah__search-empty">Type to search across all of Stella Ops.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [`
|
||||
.assistant-host__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 185;
|
||||
/* ===== Backdrop ===== */
|
||||
.ah__backdrop {
|
||||
position: fixed; inset: 0; z-index: 185;
|
||||
background: rgba(15, 23, 42, 0.16);
|
||||
backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding: 4.5rem 1rem 1rem;
|
||||
display: flex; align-items: flex-start; justify-content: flex-end;
|
||||
padding: 3rem 1rem 1rem;
|
||||
animation: ah-fade-in 300ms ease both;
|
||||
}
|
||||
@keyframes ah-fade-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
.ah--exiting { animation: ah-fade-out 250ms ease both; }
|
||||
@keyframes ah-fade-out { from { opacity: 1; } to { opacity: 0; } }
|
||||
|
||||
.assistant-host__drawer {
|
||||
/* ===== Drawer ===== */
|
||||
.ah__drawer {
|
||||
width: min(520px, calc(100vw - 2rem));
|
||||
height: min(78vh, 760px);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
background: var(--color-surface-primary);
|
||||
box-shadow: var(--shadow-dropdown);
|
||||
box-shadow: 0 8px 40px rgba(0,0,0,0.15), 0 0 0 1px rgba(245,166,35,0.08);
|
||||
overflow: hidden;
|
||||
transform-origin: bottom right;
|
||||
animation: ah-grow 400ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
@keyframes ah-grow {
|
||||
0% { opacity:0; transform: scale(0.06) translateY(40vh); border-radius:50%; }
|
||||
35% { opacity:0.9; transform: scale(0.25) translateY(22vh); border-radius:50%; }
|
||||
65% { opacity:1; transform: scale(0.8) translateY(3vh); border-radius:20px; }
|
||||
100% { opacity:1; transform: scale(1) translateY(0); border-radius: var(--radius-lg, 12px); }
|
||||
}
|
||||
.ah--exiting .ah__drawer {
|
||||
animation: ah-shrink 280ms cubic-bezier(0.36,0,0.66,-0.56) both;
|
||||
}
|
||||
@keyframes ah-shrink {
|
||||
0% { opacity:1; transform: scale(1) translateY(0); border-radius: var(--radius-lg, 12px); }
|
||||
100% { opacity:0; transform: scale(0.08) translateY(35vh); border-radius:50%; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.assistant-host__backdrop {
|
||||
padding: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
.ah__drawer--expanded {
|
||||
width: calc(100vw - 8rem); max-width: 1200px;
|
||||
height: calc(100vh - 8rem); max-height: none;
|
||||
transition: width 300ms cubic-bezier(0.22,1,0.36,1), height 300ms cubic-bezier(0.22,1,0.36,1);
|
||||
}
|
||||
|
||||
.assistant-host__drawer {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
border-radius: 0;
|
||||
/* ===== Title bar ===== */
|
||||
.ah__titlebar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
background: color-mix(in srgb, var(--color-surface-primary) 95%, var(--color-brand-primary));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ah__titlebar-left { display: flex; align-items: center; gap: 8px; }
|
||||
.ah__titlebar-icon { border-radius: 50%; }
|
||||
.ah__titlebar-text { margin:0; font-size:0.8125rem; font-weight:600; color: var(--color-text-heading); }
|
||||
.ah__titlebar-right { display: flex; align-items: center; gap: 4px; }
|
||||
|
||||
.ah__toggle-btn {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-full, 9999px);
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.625rem; font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.ah__toggle-btn:hover {
|
||||
border-color: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary);
|
||||
background: color-mix(in srgb, var(--color-brand-primary) 8%, var(--color-surface-secondary));
|
||||
}
|
||||
.ah__toggle-btn svg { width:14px; height:14px; flex-shrink:0; }
|
||||
|
||||
.ah__hdr-btn {
|
||||
width:28px; height:28px; border:none; background:transparent;
|
||||
color: var(--color-text-muted); cursor:pointer; border-radius: var(--radius-md);
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.ah__hdr-btn:hover { background: var(--color-surface-tertiary); color: var(--color-text-primary); }
|
||||
.ah__close-btn:hover { background: color-mix(in srgb, var(--color-status-error, #c62828) 10%, transparent); color: var(--color-status-error, #c62828); }
|
||||
|
||||
/* ===== Content ===== */
|
||||
.ah__content { flex:1; display:flex; flex-direction:column; min-height:0; overflow:hidden; }
|
||||
.ah__content stellaops-chat { flex:1; }
|
||||
|
||||
/* ===== Search view ===== */
|
||||
.ah__search-view { display:flex; flex-direction:column; height:100%; }
|
||||
.ah__search-bar {
|
||||
display:flex; align-items:center; gap:8px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
flex-shrink:0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.ah__search-input {
|
||||
flex:1; border:1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-full, 9999px);
|
||||
background: var(--color-surface-secondary);
|
||||
padding:6px 14px; font-size:0.75rem;
|
||||
color: var(--color-text-primary); outline:none; min-width:0;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
||||
}
|
||||
.ah__search-input::placeholder { color: var(--color-text-secondary); font-style:italic; }
|
||||
.ah__search-input:focus {
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand-primary) 12%, transparent);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
.ah__spinner {
|
||||
width:14px; height:14px; border:2px solid var(--color-border-primary);
|
||||
border-top-color: var(--color-brand-primary);
|
||||
border-radius:50%; animation: spin 0.6s linear infinite; flex-shrink:0;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.ah__search-body { flex:1; overflow-y:auto; padding:10px; }
|
||||
.ah__syn { font-size:0.75rem; line-height:1.5; color: var(--color-text-primary); padding:8px; margin-bottom:6px; border-bottom: 1px solid color-mix(in srgb, var(--color-border-primary) 25%, transparent); }
|
||||
|
||||
.ah__card {
|
||||
display:flex; flex-direction:column; gap:2px;
|
||||
padding:8px 10px; border:1px solid transparent;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
text-decoration:none; color:inherit; cursor:pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.ah__card:hover { background: var(--color-surface-secondary); border-color: color-mix(in srgb, var(--color-brand-primary) 20%, transparent); transform: translateX(2px); }
|
||||
.ah__card-type {
|
||||
font-size:0.5rem; font-weight:700; text-transform:uppercase;
|
||||
letter-spacing:0.06em; color: var(--color-brand-primary);
|
||||
display:inline-flex; align-items:center; gap:4px;
|
||||
}
|
||||
.ah__card-type::before { content:''; width:6px; height:6px; border-radius:50%; background: var(--color-brand-primary); opacity:0.5; }
|
||||
.ah__card-title { font-size:0.75rem; font-weight:600; color: var(--color-text-heading); line-height:1.3; }
|
||||
.ah__card-snip { font-size:0.625rem; color: var(--color-text-secondary); line-height:1.4; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
|
||||
.ah__search-empty { padding:24px 16px; text-align:center; color: var(--color-text-secondary); font-size:0.75rem; }
|
||||
|
||||
/* ===== Bubble trail ===== */
|
||||
.ah__trail { position:fixed; inset:0; z-index:190; pointer-events:none; }
|
||||
.ah__bubble {
|
||||
position:absolute; border-radius:50%;
|
||||
background: var(--color-surface-primary, #FFFCF5);
|
||||
border: 2px solid var(--color-brand-primary, #F5A623);
|
||||
box-shadow: 0 1px 6px rgba(245,166,35,0.15);
|
||||
opacity:0;
|
||||
animation: ah-pop 280ms cubic-bezier(0.34,1.56,0.64,1) forwards;
|
||||
}
|
||||
@keyframes ah-pop { 0%{opacity:0;transform:scale(0)} 70%{opacity:1;transform:scale(1.15)} 100%{opacity:1;transform:scale(1)} }
|
||||
|
||||
.ah__trail:not(.ah__trail--streaming) .ah__bubble {
|
||||
animation: ah-pop 280ms cubic-bezier(0.34,1.56,0.64,1) forwards, ah-breathe 3s ease-in-out 700ms infinite;
|
||||
}
|
||||
@keyframes ah-breathe { 0%,100%{transform:scale(1);opacity:0.8} 50%{transform:scale(1.06);opacity:1} }
|
||||
|
||||
.ah__trail--streaming .ah__bubble {
|
||||
animation: ah-pop 280ms cubic-bezier(0.34,1.56,0.64,1) forwards, ah-pulse 0.7s ease-in-out infinite;
|
||||
}
|
||||
@keyframes ah-pulse {
|
||||
0%,100% { transform:scale(1); opacity:0.6; box-shadow: 0 0 4px rgba(245,166,35,0.2); }
|
||||
50% { transform:scale(1.4); opacity:1; box-shadow: 0 0 18px rgba(245,166,35,0.55); }
|
||||
}
|
||||
|
||||
.ah--exiting .ah__bubble { animation: ah-vanish 180ms ease forwards !important; }
|
||||
@keyframes ah-vanish { to { opacity:0; transform:scale(0); } }
|
||||
|
||||
/* Entrance trail pseudo-elements */
|
||||
.ah--entering .ah__backdrop::before,
|
||||
.ah--entering .ah__backdrop::after {
|
||||
content:''; position:fixed; border-radius:50%;
|
||||
background: var(--color-brand-primary, #F5A623);
|
||||
pointer-events:none; z-index:191;
|
||||
}
|
||||
.ah--entering .ah__backdrop::before { width:14px;height:14px; animation: ah-fly1 380ms cubic-bezier(0.18,0.89,0.32,1) both; }
|
||||
.ah--entering .ah__backdrop::after { width:9px;height:9px; animation: ah-fly2 420ms cubic-bezier(0.18,0.89,0.32,1) 60ms both; }
|
||||
@keyframes ah-fly1 { 0%{bottom:56px;right:80px;opacity:0.8;transform:scale(0.4)} 50%{opacity:0.6;transform:scale(1)} 100%{bottom:calc(100vh - 5rem);right:50%;opacity:0;transform:scale(0.2)} }
|
||||
@keyframes ah-fly2 { 0%{bottom:50px;right:90px;opacity:0.6;transform:scale(0.3)} 60%{opacity:0.5;transform:scale(0.8)} 100%{bottom:calc(100vh - 7rem);right:45%;opacity:0;transform:scale(0.15)} }
|
||||
|
||||
/* ===== Mobile ===== */
|
||||
@media (max-width: 900px) {
|
||||
.ah__backdrop { padding:0; align-items:stretch; }
|
||||
.ah__drawer { width:100vw; height:100vh; border-radius:0; animation: ah-mobile 250ms cubic-bezier(0.18,0.89,0.32,1) both; }
|
||||
@keyframes ah-mobile { from{opacity:0;transform:scale(0.96)} to{opacity:1;transform:scale(1)} }
|
||||
.ah__trail { display:none; }
|
||||
.ah--entering .ah__backdrop::before, .ah--entering .ah__backdrop::after { display:none; }
|
||||
}
|
||||
|
||||
/* ===== Reduced motion ===== */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ah__drawer, .ah__bubble, .ah__backdrop,
|
||||
.ah--entering .ah__backdrop::before, .ah--entering .ah__backdrop::after {
|
||||
animation: none !important; opacity:1 !important;
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class SearchAssistantHostComponent {
|
||||
private readonly router = inject(Router);
|
||||
readonly assistantDrawer = inject(SearchAssistantDrawerService);
|
||||
readonly context = inject(PlatformContextStore);
|
||||
readonly stella = inject(StellaAssistantService);
|
||||
|
||||
@ViewChild('assistantDrawerElement') private drawerRef?: ElementRef<HTMLElement>;
|
||||
readonly entering = signal(false);
|
||||
readonly exiting = signal(false);
|
||||
readonly drawerExpanded = signal(false);
|
||||
readonly chatVisible = signal(true);
|
||||
readonly searchViewQuery = signal('');
|
||||
readonly trailBubbles = signal<TrailBubble[]>([]);
|
||||
|
||||
@ViewChild('drawerEl') private drawerRef?: ElementRef<HTMLElement>;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.assistantDrawer.isOpen()) {
|
||||
return;
|
||||
if (this.assistantDrawer.isOpen()) {
|
||||
this.entering.set(true);
|
||||
this.chatVisible.set(true);
|
||||
setTimeout(() => { this.entering.set(false); this.computeTrail(); }, 500);
|
||||
setTimeout(() => this.drawerRef?.nativeElement?.focus(), 100);
|
||||
setTimeout(() => this.computeTrail(), 50);
|
||||
}
|
||||
|
||||
setTimeout(() => this.drawerRef?.nativeElement?.focus(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.assistantDrawer.close();
|
||||
this.exiting.set(true);
|
||||
setTimeout(() => {
|
||||
this.exiting.set(false);
|
||||
this.trailBubbles.set([]);
|
||||
this.assistantDrawer.close();
|
||||
}, 280);
|
||||
}
|
||||
|
||||
onToggleView(): void { this.stella.toggleDrawerView(); }
|
||||
|
||||
onNewSession(): void {
|
||||
this.stella.newSession();
|
||||
this.chatVisible.set(false);
|
||||
setTimeout(() => this.chatVisible.set(true), 0);
|
||||
}
|
||||
|
||||
onToggleExpand(): void {
|
||||
this.drawerExpanded.update(v => !v);
|
||||
setTimeout(() => this.computeTrail(), 350);
|
||||
}
|
||||
|
||||
onConversationCreated(conversationId: string): void {
|
||||
this.stella.setActiveConversation(conversationId);
|
||||
}
|
||||
|
||||
onChatTyping(value: string): void {
|
||||
// Auto-derive search results from chat input (only in chat view)
|
||||
if (this.stella.drawerView() === 'chat' && value.trim().length >= 2) {
|
||||
this.stella.searchAsYouType(value);
|
||||
}
|
||||
}
|
||||
|
||||
onSearchViewInput(event: Event): void {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
this.searchViewQuery.set(value);
|
||||
if (value.trim().length >= 2) {
|
||||
this.stella.searchAsYouType(value);
|
||||
} else if (!value.trim()) {
|
||||
this.stella.clearSearch();
|
||||
}
|
||||
}
|
||||
|
||||
getCardRoute(card: any): string {
|
||||
const nav = card.actions?.find((a: any) => a.actionType === 'navigate');
|
||||
return nav?.route ?? '#';
|
||||
}
|
||||
|
||||
onCardClick(event: Event, card: any): void {
|
||||
event.preventDefault();
|
||||
const route = this.getCardRoute(card);
|
||||
if (route && route !== '#') {
|
||||
this.assistantDrawer.close();
|
||||
this.trailBubbles.set([]);
|
||||
this.router.navigateByUrl(route);
|
||||
}
|
||||
}
|
||||
|
||||
onSearchForMore(query: string): void {
|
||||
if (query.trim()) {
|
||||
this.assistantDrawer.close({ restoreFocus: false });
|
||||
this.stella.drawerView.set('search');
|
||||
this.searchViewQuery.set(query);
|
||||
this.stella.searchAsYouType(query);
|
||||
}
|
||||
}
|
||||
|
||||
onBackdropClick(event: MouseEvent): void {
|
||||
if (event.target === event.currentTarget) {
|
||||
this.close();
|
||||
}
|
||||
if (event.target === event.currentTarget) this.close();
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
if (this.assistantDrawer.isOpen()) {
|
||||
this.close();
|
||||
if (this.assistantDrawer.isOpen() && !this.exiting()) this.close();
|
||||
}
|
||||
|
||||
@HostListener('window:resize')
|
||||
onResize(): void {
|
||||
if (this.assistantDrawer.isOpen()) this.computeTrail();
|
||||
}
|
||||
|
||||
private computeTrail(): void {
|
||||
const mascotEl = document.querySelector('[aria-label="Stella helper assistant"] button');
|
||||
const drawerEl = this.drawerRef?.nativeElement;
|
||||
if (!mascotEl || !drawerEl) { this.trailBubbles.set([]); return; }
|
||||
|
||||
const mR = mascotEl.getBoundingClientRect();
|
||||
const dR = drawerEl.getBoundingClientRect();
|
||||
|
||||
const p0x = mR.left, p0y = mR.top + mR.height / 2;
|
||||
const p2x = dR.left + 20, p2y = dR.bottom - 30;
|
||||
const p1x = dR.left - 80, p1y = p2y + 100;
|
||||
|
||||
const dist = Math.hypot(p2x - p0x, p2y - p0y);
|
||||
const density = this.drawerExpanded() ? 35 : 45;
|
||||
const count = Math.max(8, Math.min(20, Math.round(dist / density)));
|
||||
|
||||
const bubbles: TrailBubble[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const linear = i / (count - 1);
|
||||
const t = Math.pow(linear, 1.8);
|
||||
const mt = 1 - t;
|
||||
const x = mt*mt*p0x + 2*mt*t*p1x + t*t*p2x;
|
||||
const y = mt*mt*p0y + 2*mt*t*p1y + t*t*p2y;
|
||||
bubbles.push({ x, y, size: 4 + 20 * linear, delay: 50 + i * 50 });
|
||||
}
|
||||
this.trailBubbles.set(bubbles);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
/**
|
||||
* StellaAssistantService — Unified mascot + search + AI chat service.
|
||||
*
|
||||
* Three modes:
|
||||
* tips — DB-backed, locale-aware contextual tips (default)
|
||||
* search — Delegates to UnifiedSearchClient (same as Ctrl+K)
|
||||
* chat — Delegates to ChatService (Advisory AI with SSE)
|
||||
*
|
||||
* Tips are fetched from the backend API per route + locale.
|
||||
* Falls back to the static tips config if the API is unavailable.
|
||||
*/
|
||||
|
||||
import { Injectable, inject, signal, computed, effect } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Router, NavigationEnd } from '@angular/router';
|
||||
import { filter, firstValueFrom, catchError, of, Subject, Subscription } from 'rxjs';
|
||||
|
||||
import { I18nService } from '../../../core/i18n/i18n.service';
|
||||
import { ChatService } from '../../../features/advisory-ai/chat/chat.service';
|
||||
import { SearchAssistantDrawerService } from '../../../core/services/search-assistant-drawer.service';
|
||||
import { SearchChatContextService } from '../../../core/services/search-chat-context.service';
|
||||
import { UnifiedSearchClient } from '../../../core/api/unified-search.client';
|
||||
import type { EntityCard, SynthesisResult } from '../../../core/api/unified-search.models';
|
||||
import {
|
||||
StellaHelperTip,
|
||||
StellaHelperPageConfig,
|
||||
PAGE_TIPS,
|
||||
resolvePageKey,
|
||||
} from './stella-helper-tips.config';
|
||||
import type { StellaContextKey } from './stella-helper-context.service';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API response models
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AssistantTipDto {
|
||||
tipId: string;
|
||||
title: string;
|
||||
body: string;
|
||||
action?: { label: string; route: string };
|
||||
contextTrigger?: string;
|
||||
}
|
||||
|
||||
export interface AssistantTipsResponse {
|
||||
greeting: string;
|
||||
tips: AssistantTipDto[];
|
||||
contextTips: AssistantTipDto[];
|
||||
}
|
||||
|
||||
export interface GlossaryTermDto {
|
||||
termId: string;
|
||||
term: string;
|
||||
definition: string;
|
||||
extendedHelp?: string;
|
||||
relatedTerms: string[];
|
||||
relatedRoutes: string[];
|
||||
}
|
||||
|
||||
export interface GlossaryResponse {
|
||||
terms: GlossaryTermDto[];
|
||||
}
|
||||
|
||||
export interface AssistantUserState {
|
||||
seenRoutes: string[];
|
||||
completedTours: string[];
|
||||
tipPositions: Record<string, number>;
|
||||
dismissed: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Mascot modes: tips (contextual help) or search (inline results). Chat opens the full drawer. */
|
||||
export type AssistantMode = 'tips' | 'search';
|
||||
|
||||
const API_BASE = '/api/v1/stella-assistant';
|
||||
const USER_STATE_STORAGE_KEY = 'stellaops.assistant.state';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StellaAssistantService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly router = inject(Router);
|
||||
private readonly i18n = inject(I18nService);
|
||||
private readonly assistantDrawer = inject(SearchAssistantDrawerService);
|
||||
private readonly chatService = inject(ChatService);
|
||||
private readonly searchClient = inject(UnifiedSearchClient);
|
||||
private readonly searchChatCtx = inject(SearchChatContextService);
|
||||
|
||||
private routerSub?: Subscription;
|
||||
|
||||
// ---- Mode ----
|
||||
readonly mode = signal<AssistantMode>('tips');
|
||||
readonly isOpen = signal(false);
|
||||
readonly isMinimized = signal(false);
|
||||
|
||||
// ---- Tips (API-backed with static fallback) ----
|
||||
readonly greeting = signal('');
|
||||
readonly tips = signal<StellaHelperTip[]>([]);
|
||||
readonly contextTips = signal<StellaHelperTip[]>([]);
|
||||
readonly currentTipIndex = signal(0);
|
||||
readonly tipsLoading = signal(false);
|
||||
private tipsApiAvailable = true; // flip to false on first failure
|
||||
|
||||
// ---- Context ----
|
||||
readonly currentRoute = signal('/');
|
||||
readonly currentPageKey = signal('dashboard');
|
||||
readonly activeContexts = signal<StellaContextKey[]>([]);
|
||||
|
||||
// ---- Glossary ----
|
||||
readonly glossary = signal<GlossaryTermDto[]>([]);
|
||||
private glossaryLoaded = false;
|
||||
|
||||
// ---- Search ----
|
||||
readonly searchQuery = signal('');
|
||||
readonly searchResults = signal<EntityCard[]>([]);
|
||||
readonly searchSynthesis = signal<SynthesisResult | null>(null);
|
||||
readonly searchLoading = signal(false);
|
||||
private searchSub?: Subscription;
|
||||
|
||||
// ---- Chat animation state ----
|
||||
readonly chatAnimationState = signal<'idle' | 'thinking' | 'typing' | 'done' | 'error'>('idle');
|
||||
private chatStreamSub?: Subscription;
|
||||
|
||||
// ---- Session persistence ----
|
||||
readonly activeConversationId = signal<string | null>(null);
|
||||
readonly conversationHistory = signal<string[]>([]);
|
||||
|
||||
// ---- Drawer view toggle (chat vs search) ----
|
||||
readonly drawerView = signal<'chat' | 'search'>('chat');
|
||||
|
||||
// ---- User state ----
|
||||
readonly userState = signal<AssistantUserState>(this.loadLocalState());
|
||||
|
||||
// ---- Derived ----
|
||||
readonly effectiveTips = computed<StellaHelperTip[]>(() => {
|
||||
const ctx = this.contextTips();
|
||||
const page = this.tips();
|
||||
// Context tips prepended (higher priority), deduped
|
||||
const combined = [...ctx];
|
||||
for (const t of page) {
|
||||
if (!combined.some(c => c.title === t.title)) {
|
||||
combined.push(t);
|
||||
}
|
||||
}
|
||||
return combined;
|
||||
});
|
||||
|
||||
readonly totalTips = computed(() => this.effectiveTips().length);
|
||||
|
||||
readonly currentTip = computed<StellaHelperTip | null>(() => {
|
||||
const tips = this.effectiveTips();
|
||||
const idx = this.currentTipIndex();
|
||||
return tips[idx] ?? null;
|
||||
});
|
||||
|
||||
readonly isFirstVisit = computed(() => {
|
||||
const key = this.currentPageKey();
|
||||
return !this.userState().seenRoutes.includes(key);
|
||||
});
|
||||
|
||||
// Chat is handled by SearchAssistantDrawerService (full ChatComponent)
|
||||
|
||||
constructor() {
|
||||
// Persist user state on change
|
||||
effect(() => this.saveLocalState(this.userState()));
|
||||
}
|
||||
|
||||
// ---- Lifecycle ----
|
||||
|
||||
init(): void {
|
||||
// Set initial page
|
||||
this.onRouteChange(this.router.url);
|
||||
|
||||
// Listen for route changes
|
||||
this.routerSub = this.router.events
|
||||
.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
|
||||
.subscribe(e => this.onRouteChange(e.urlAfterRedirects ?? e.url));
|
||||
|
||||
// Load glossary once
|
||||
this.loadGlossary();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.routerSub?.unsubscribe();
|
||||
}
|
||||
|
||||
// ---- Mode transitions ----
|
||||
|
||||
enterTips(): void {
|
||||
this.mode.set('tips');
|
||||
this.searchQuery.set('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the global search bar with a query.
|
||||
* The mascot does NOT have its own search — it delegates to the
|
||||
* top-bar GlobalSearchComponent for a single, consistent experience.
|
||||
*
|
||||
* Uses the native input setter + input event to trigger Angular's
|
||||
* ngModel binding correctly (dispatchEvent with InputEvent, not Event).
|
||||
*/
|
||||
private searchDebounceTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
/**
|
||||
* Search-as-you-type with 200ms debounce.
|
||||
* Called on every keystroke in the mascot input.
|
||||
* Results appear inline in the mascot bubble above the input.
|
||||
*/
|
||||
searchAsYouType(query: string): void {
|
||||
this.searchQuery.set(query);
|
||||
|
||||
if (this.searchDebounceTimer) clearTimeout(this.searchDebounceTimer);
|
||||
|
||||
if (!query.trim() || query.trim().length < 2) {
|
||||
this.searchResults.set([]);
|
||||
this.searchSynthesis.set(null);
|
||||
this.searchLoading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.searchLoading.set(true);
|
||||
|
||||
this.searchDebounceTimer = setTimeout(() => {
|
||||
this.searchSub?.unsubscribe();
|
||||
this.searchSub = this.searchClient.search(query.trim(), undefined, 5, {
|
||||
currentRoute: this.currentRoute(),
|
||||
}).subscribe({
|
||||
next: (resp) => {
|
||||
this.searchResults.set(resp.cards);
|
||||
this.searchSynthesis.set(resp.synthesis);
|
||||
this.searchLoading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.searchResults.set([]);
|
||||
this.searchLoading.set(false);
|
||||
},
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/** Clear search results (when input is emptied). */
|
||||
clearSearch(): void {
|
||||
this.searchQuery.set('');
|
||||
this.searchResults.set([]);
|
||||
this.searchSynthesis.set(null);
|
||||
this.searchLoading.set(false);
|
||||
this.searchSub?.unsubscribe();
|
||||
if (this.searchDebounceTimer) clearTimeout(this.searchDebounceTimer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the AI chat drawer with the full ChatComponent.
|
||||
* The mascot does NOT embed chat — it delegates to the proper drawer.
|
||||
*
|
||||
* ALL AI chat entry points converge here:
|
||||
* - Mascot "Ask Stella" button
|
||||
* - Mascot question input (detected as question)
|
||||
* - Search bar "Open deeper help" icon (handled by GlobalSearchComponent directly)
|
||||
*/
|
||||
openChat(initialMessage?: string): void {
|
||||
// Pass search context to the chat if we have results
|
||||
if (initialMessage || this.searchResults().length > 0) {
|
||||
this.searchChatCtx.setSearchToChat({
|
||||
query: initialMessage ?? this.searchQuery(),
|
||||
entityCards: this.searchResults(),
|
||||
synthesis: this.searchSynthesis(),
|
||||
suggestedPrompt: initialMessage ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Close the mascot bubble — the drawer takes over
|
||||
this.isOpen.set(false);
|
||||
this.drawerView.set('chat');
|
||||
|
||||
// If resuming an existing conversation, don't send initialMessage again
|
||||
const resuming = this.activeConversationId() && !initialMessage;
|
||||
|
||||
// Open the full chat drawer
|
||||
this.assistantDrawer.open({
|
||||
initialUserMessage: resuming ? null : (initialMessage ?? null),
|
||||
source: 'stella_assistant',
|
||||
});
|
||||
|
||||
// Connect mascot animation to chat stream events
|
||||
this.chatStreamSub?.unsubscribe();
|
||||
this.chatAnimationState.set('idle');
|
||||
this.chatStreamSub = this.chatService.streamEvents.subscribe((event) => {
|
||||
const type = (event as any).event ?? '';
|
||||
switch (type) {
|
||||
case 'start':
|
||||
case 'progress':
|
||||
this.chatAnimationState.set('thinking');
|
||||
break;
|
||||
case 'token':
|
||||
this.chatAnimationState.set('typing');
|
||||
break;
|
||||
case 'done':
|
||||
this.chatAnimationState.set('done');
|
||||
setTimeout(() => this.chatAnimationState.set('idle'), 2000);
|
||||
break;
|
||||
case 'error':
|
||||
this.chatAnimationState.set('error');
|
||||
setTimeout(() => this.chatAnimationState.set('idle'), 3000);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.isOpen.set(true);
|
||||
this.isMinimized.set(false);
|
||||
}
|
||||
|
||||
// ---- Session management ----
|
||||
|
||||
setActiveConversation(id: string): void {
|
||||
this.activeConversationId.set(id);
|
||||
}
|
||||
|
||||
newSession(): void {
|
||||
const currentId = this.activeConversationId();
|
||||
if (currentId) {
|
||||
this.conversationHistory.update(h =>
|
||||
h.includes(currentId) ? h : [...h, currentId]
|
||||
);
|
||||
}
|
||||
this.chatService.clearConversation();
|
||||
this.activeConversationId.set(null);
|
||||
}
|
||||
|
||||
toggleDrawerView(): void {
|
||||
this.drawerView.update(v => v === 'chat' ? 'search' : 'chat');
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.isOpen.set(false);
|
||||
}
|
||||
|
||||
minimize(): void {
|
||||
this.isMinimized.set(true);
|
||||
}
|
||||
|
||||
dismiss(): void {
|
||||
this.isOpen.set(false);
|
||||
this.userState.update(s => ({ ...s, dismissed: true }));
|
||||
}
|
||||
|
||||
restore(): void {
|
||||
this.userState.update(s => ({ ...s, dismissed: false }));
|
||||
this.isOpen.set(true);
|
||||
this.isMinimized.set(false);
|
||||
}
|
||||
|
||||
// ---- Tips ----
|
||||
|
||||
nextTip(): void {
|
||||
const max = this.totalTips() - 1;
|
||||
if (this.currentTipIndex() < max) {
|
||||
this.currentTipIndex.update(i => i + 1);
|
||||
this.saveTipPosition();
|
||||
}
|
||||
}
|
||||
|
||||
prevTip(): void {
|
||||
if (this.currentTipIndex() > 0) {
|
||||
this.currentTipIndex.update(i => i - 1);
|
||||
this.saveTipPosition();
|
||||
}
|
||||
}
|
||||
|
||||
navigateToAction(route: string): void {
|
||||
this.close();
|
||||
this.router.navigateByUrl(route);
|
||||
}
|
||||
|
||||
// ---- Context ----
|
||||
|
||||
pushContext(key: StellaContextKey): void {
|
||||
this.activeContexts.update(list => {
|
||||
if (list.includes(key)) return list;
|
||||
return [...list, key];
|
||||
});
|
||||
// Re-load tips with new context
|
||||
this.loadTipsForCurrentRoute();
|
||||
}
|
||||
|
||||
pushContexts(keys: StellaContextKey[]): void {
|
||||
this.activeContexts.update(list => {
|
||||
const next = [...list];
|
||||
let changed = false;
|
||||
for (const k of keys) {
|
||||
if (!next.includes(k)) {
|
||||
next.push(k);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : list;
|
||||
});
|
||||
this.loadTipsForCurrentRoute();
|
||||
}
|
||||
|
||||
removeContext(key: StellaContextKey): void {
|
||||
this.activeContexts.update(list => list.filter(k => k !== key));
|
||||
}
|
||||
|
||||
clearContexts(): void {
|
||||
this.activeContexts.set([]);
|
||||
}
|
||||
|
||||
// ---- Internal ----
|
||||
|
||||
private async onRouteChange(url: string): Promise<void> {
|
||||
const path = url.split('?')[0].split('#')[0];
|
||||
const key = resolvePageKey(url);
|
||||
|
||||
if (key === this.currentPageKey() && path === this.currentRoute()) return;
|
||||
|
||||
this.clearContexts();
|
||||
this.currentRoute.set(path);
|
||||
this.currentPageKey.set(key);
|
||||
|
||||
// Restore tip position
|
||||
const saved = this.userState().tipPositions[key];
|
||||
this.currentTipIndex.set(saved ?? 0);
|
||||
|
||||
// Load tips from API (or fallback to static)
|
||||
await this.loadTipsForCurrentRoute();
|
||||
|
||||
// Auto-open on first visit
|
||||
if (this.isFirstVisit() && !this.userState().dismissed) {
|
||||
setTimeout(() => {
|
||||
this.isOpen.set(true);
|
||||
this.isMinimized.set(false);
|
||||
this.markRouteSeen(key);
|
||||
}, 800);
|
||||
}
|
||||
|
||||
// If in search/chat mode, return to tips on navigation
|
||||
if (this.mode() === 'search') {
|
||||
this.enterTips();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadTipsForCurrentRoute(): Promise<void> {
|
||||
const route = this.currentRoute();
|
||||
const locale = this.i18n.locale();
|
||||
const contexts = this.activeContexts().join(',');
|
||||
|
||||
if (this.tipsApiAvailable) {
|
||||
this.tipsLoading.set(true);
|
||||
try {
|
||||
const params: Record<string, string> = { route, locale };
|
||||
if (contexts) params['contexts'] = contexts;
|
||||
|
||||
const resp = await firstValueFrom(
|
||||
this.http.get<AssistantTipsResponse>(`${API_BASE}/tips`, { params }).pipe(
|
||||
catchError(() => {
|
||||
this.tipsApiAvailable = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
if (resp) {
|
||||
this.greeting.set(resp.greeting);
|
||||
this.tips.set(resp.tips.map(this.dtoToTip));
|
||||
this.contextTips.set(resp.contextTips.map(this.dtoToTip));
|
||||
this.tipsLoading.set(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
this.tipsApiAvailable = false;
|
||||
}
|
||||
this.tipsLoading.set(false);
|
||||
}
|
||||
|
||||
// Fallback to static tips
|
||||
this.loadStaticTips();
|
||||
}
|
||||
|
||||
private loadStaticTips(): void {
|
||||
const key = this.currentPageKey();
|
||||
const config = PAGE_TIPS[key] ?? PAGE_TIPS['default'];
|
||||
this.greeting.set(config.greeting);
|
||||
this.tips.set(config.tips);
|
||||
|
||||
// Find context-triggered tips from static config
|
||||
const contexts = this.activeContexts();
|
||||
if (contexts.length > 0) {
|
||||
const ctxTips: StellaHelperTip[] = [];
|
||||
for (const cfg of Object.values(PAGE_TIPS)) {
|
||||
for (const tip of cfg.tips) {
|
||||
if (tip.contextTrigger && contexts.includes(tip.contextTrigger)) {
|
||||
if (!ctxTips.some(t => t.title === tip.title)) {
|
||||
ctxTips.push(tip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.contextTips.set(ctxTips);
|
||||
} else {
|
||||
this.contextTips.set([]);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadGlossary(): Promise<void> {
|
||||
if (this.glossaryLoaded) return;
|
||||
try {
|
||||
const locale = this.i18n.locale();
|
||||
const resp = await firstValueFrom(
|
||||
this.http.get<GlossaryResponse>(`${API_BASE}/glossary`, { params: { locale } }).pipe(
|
||||
catchError(() => of({ terms: [] }))
|
||||
)
|
||||
);
|
||||
this.glossary.set(resp.terms);
|
||||
this.glossaryLoaded = true;
|
||||
} catch {
|
||||
// Glossary unavailable, not critical
|
||||
}
|
||||
}
|
||||
|
||||
private markRouteSeen(key: string): void {
|
||||
this.userState.update(s => {
|
||||
if (s.seenRoutes.includes(key)) return s;
|
||||
return { ...s, seenRoutes: [...s.seenRoutes, key] };
|
||||
});
|
||||
}
|
||||
|
||||
private saveTipPosition(): void {
|
||||
const key = this.currentPageKey();
|
||||
const idx = this.currentTipIndex();
|
||||
this.userState.update(s => ({
|
||||
...s,
|
||||
tipPositions: { ...s.tipPositions, [key]: idx },
|
||||
}));
|
||||
}
|
||||
|
||||
private dtoToTip(dto: AssistantTipDto): StellaHelperTip {
|
||||
return {
|
||||
title: dto.title,
|
||||
body: dto.body,
|
||||
action: dto.action,
|
||||
contextTrigger: dto.contextTrigger,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Persistence (localStorage with future API sync) ----
|
||||
|
||||
private loadLocalState(): AssistantUserState {
|
||||
try {
|
||||
const raw = localStorage.getItem(USER_STATE_STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
seenRoutes: Array.isArray(parsed.seenRoutes) ? parsed.seenRoutes : [],
|
||||
completedTours: Array.isArray(parsed.completedTours) ? parsed.completedTours : [],
|
||||
tipPositions: parsed.tipPositions && typeof parsed.tipPositions === 'object' ? parsed.tipPositions : {},
|
||||
dismissed: typeof parsed.dismissed === 'boolean' ? parsed.dismissed : false,
|
||||
};
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return { seenRoutes: [], completedTours: [], tipPositions: {}, dismissed: false };
|
||||
}
|
||||
|
||||
private saveLocalState(state: AssistantUserState): void {
|
||||
try {
|
||||
localStorage.setItem(USER_STATE_STORAGE_KEY, JSON.stringify(state));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Future: sync state to backend API
|
||||
// async syncStateToServer(): Promise<void> {
|
||||
// await firstValueFrom(
|
||||
// this.http.put(`${API_BASE}/user-state`, this.userState())
|
||||
// );
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Injectable, signal, computed } from '@angular/core';
|
||||
|
||||
/**
|
||||
* StellaHelperContextService — Reactive context injection for the Stella Helper.
|
||||
*
|
||||
* Page/tab components inject this service and push context signals when they
|
||||
* detect states that the helper should react to. The helper subscribes to
|
||||
* these signals and surfaces context-triggered tips with priority.
|
||||
*
|
||||
* Architecture:
|
||||
* - Components push context keys (e.g., 'sbom-missing', 'gate-blocked')
|
||||
* - Helper reads activeContexts() signal and matches against tip.contextTrigger
|
||||
* - Context-triggered tips take priority over generic page tips
|
||||
* - Contexts are cleared automatically on navigation (page change)
|
||||
*
|
||||
* Usage in a component:
|
||||
* ```ts
|
||||
* private helperCtx = inject(StellaHelperContextService);
|
||||
*
|
||||
* ngOnInit() {
|
||||
* if (this.environments().some(e => e.sbomStatus === 'missing')) {
|
||||
* this.helperCtx.push('sbom-missing');
|
||||
* }
|
||||
* if (this.riskBudget() > 70) {
|
||||
* this.helperCtx.push('budget-exceeded');
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
/** Well-known context keys that page components can push. */
|
||||
export type StellaContextKey =
|
||||
// Dashboard / global
|
||||
| 'sbom-missing'
|
||||
| 'health-unknown'
|
||||
| 'no-environments'
|
||||
| 'feed-stale'
|
||||
| 'feed-never-synced'
|
||||
// Releases
|
||||
| 'gate-blocked'
|
||||
| 'gate-warn'
|
||||
| 'evidence-missing'
|
||||
| 'release-draft'
|
||||
| 'release-failed'
|
||||
| 'approval-pending'
|
||||
| 'approval-expired'
|
||||
// Security
|
||||
| 'critical-open'
|
||||
| 'high-open'
|
||||
| 'no-findings'
|
||||
| 'no-sbom-components'
|
||||
| 'reachability-low'
|
||||
| 'unknowns-present'
|
||||
// Policy
|
||||
| 'budget-exceeded'
|
||||
| 'budget-critical'
|
||||
| 'no-baseline'
|
||||
| 'policy-conflict'
|
||||
| 'sealed-mode-active'
|
||||
| 'shadow-mode-active'
|
||||
// VEX
|
||||
| 'vex-zero'
|
||||
| 'vex-conflicts'
|
||||
| 'exceptions-pending'
|
||||
| 'exceptions-expiring'
|
||||
// Evidence
|
||||
| 'no-capsules'
|
||||
| 'no-audit-events'
|
||||
| 'proof-chain-broken'
|
||||
// Operations
|
||||
| 'dead-letters'
|
||||
| 'jobs-failed'
|
||||
| 'agents-none'
|
||||
| 'agents-degraded'
|
||||
| 'signals-degraded'
|
||||
// Integrations
|
||||
| 'no-integrations'
|
||||
| 'integration-error'
|
||||
// Empty states (generic)
|
||||
| 'empty-table'
|
||||
| 'empty-list'
|
||||
| 'first-visit'
|
||||
// Custom (string allows extension without modifying the type)
|
||||
| (string & {});
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StellaHelperContextService {
|
||||
/** Active context keys pushed by page components. */
|
||||
private readonly _contexts = signal<Set<StellaContextKey>>(new Set());
|
||||
|
||||
/** Read-only signal of active contexts. */
|
||||
readonly activeContexts = computed(() => [...this._contexts()]);
|
||||
|
||||
/** Whether any context is currently active. */
|
||||
readonly hasContext = computed(() => this._contexts().size > 0);
|
||||
|
||||
/**
|
||||
* Push a context key. The helper will check if any tips match
|
||||
* this context and surface them with priority.
|
||||
*/
|
||||
push(key: StellaContextKey): void {
|
||||
this._contexts.update(set => {
|
||||
if (set.has(key)) return set;
|
||||
const next = new Set(set);
|
||||
next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Push multiple context keys at once.
|
||||
*/
|
||||
pushAll(keys: StellaContextKey[]): void {
|
||||
this._contexts.update(set => {
|
||||
const next = new Set(set);
|
||||
let changed = false;
|
||||
for (const k of keys) {
|
||||
if (!next.has(k)) {
|
||||
next.add(k);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : set;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific context (e.g., when state changes).
|
||||
*/
|
||||
remove(key: StellaContextKey): void {
|
||||
this._contexts.update(set => {
|
||||
if (!set.has(key)) return set;
|
||||
const next = new Set(set);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all contexts. Called automatically on route change
|
||||
* by the StellaHelperComponent.
|
||||
*/
|
||||
clear(): void {
|
||||
this._contexts.set(new Set());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific context is active.
|
||||
*/
|
||||
has(key: StellaContextKey): boolean {
|
||||
return this._contexts().has(key);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,466 @@
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { StellaAssistantService } from './stella-assistant.service';
|
||||
|
||||
/**
|
||||
* Tour step definition (from DB or static).
|
||||
*/
|
||||
export interface TourStep {
|
||||
stepOrder: number;
|
||||
route: string;
|
||||
selector?: string;
|
||||
title: string;
|
||||
body: string;
|
||||
action?: { label: string; route: string };
|
||||
}
|
||||
|
||||
export interface Tour {
|
||||
tourKey: string;
|
||||
title: string;
|
||||
description: string;
|
||||
steps: TourStep[];
|
||||
}
|
||||
|
||||
/**
|
||||
* StellaTourComponent — Guided walkthrough engine.
|
||||
*
|
||||
* Shows the Stella mascot at each step, highlighting the target element
|
||||
* with a backdrop overlay. Steps can navigate to different pages.
|
||||
*
|
||||
* Usage:
|
||||
* <app-stella-tour
|
||||
* [tour]="activeTour()"
|
||||
* (tourComplete)="onTourDone()"
|
||||
* (tourCancel)="onTourCancel()"
|
||||
* />
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-stella-tour',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
template: `
|
||||
@if (isActive()) {
|
||||
<!-- Backdrop overlay -->
|
||||
<div class="tour-backdrop" (click)="onSkip()">
|
||||
@if (highlightRect(); as rect) {
|
||||
<div
|
||||
class="tour-highlight"
|
||||
[style.top.px]="rect.top - 4"
|
||||
[style.left.px]="rect.left - 4"
|
||||
[style.width.px]="rect.width + 8"
|
||||
[style.height.px]="rect.height + 8"
|
||||
></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Step card -->
|
||||
<div
|
||||
class="tour-card"
|
||||
[style.top.px]="cardPosition().top"
|
||||
[style.left.px]="cardPosition().left"
|
||||
role="dialog"
|
||||
aria-label="Tour step"
|
||||
>
|
||||
<div class="tour-card__header">
|
||||
<img
|
||||
src="assets/img/stella-ops-icon.png"
|
||||
alt="Stella"
|
||||
class="tour-card__mascot"
|
||||
width="32"
|
||||
height="32"
|
||||
/>
|
||||
<div class="tour-card__meta">
|
||||
<span class="tour-card__tour-title">{{ tourTitle() }}</span>
|
||||
<span class="tour-card__step-count">Step {{ currentStepIndex() + 1 }} of {{ totalSteps() }}</span>
|
||||
</div>
|
||||
<button class="tour-card__close" (click)="onSkip()" title="Skip tour" aria-label="Skip tour">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
||||
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tour-card__body">
|
||||
<div class="tour-card__title">{{ currentStep()?.title }}</div>
|
||||
<div class="tour-card__text">{{ currentStep()?.body }}</div>
|
||||
|
||||
@if (currentStep()?.action; as action) {
|
||||
<button class="tour-card__action" (click)="onStepAction(action.route)">
|
||||
{{ action.label }}
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" aria-hidden="true">
|
||||
<polyline points="9 18 15 12 9 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="tour-card__progress">
|
||||
<div class="tour-card__progress-fill" [style.width.%]="progressPercent()"></div>
|
||||
</div>
|
||||
|
||||
<div class="tour-card__nav">
|
||||
<button
|
||||
class="tour-card__nav-btn"
|
||||
(click)="onPrev()"
|
||||
[disabled]="currentStepIndex() === 0"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
|
||||
@if (currentStepIndex() < totalSteps() - 1) {
|
||||
<button class="tour-card__nav-btn tour-card__nav-btn--primary" (click)="onNext()">
|
||||
Next
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" aria-hidden="true">
|
||||
<polyline points="9 18 15 12 9 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
} @else {
|
||||
<button class="tour-card__nav-btn tour-card__nav-btn--primary" (click)="onFinish()">
|
||||
Done
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [`
|
||||
.tour-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 900;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(2px);
|
||||
animation: tour-fade-in 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes tour-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.tour-highlight {
|
||||
position: absolute;
|
||||
border: 2px solid var(--color-brand-primary);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
box-shadow:
|
||||
0 0 0 9999px rgba(0, 0, 0, 0.55),
|
||||
0 0 24px rgba(245, 166, 35, 0.4);
|
||||
pointer-events: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tour-card {
|
||||
position: fixed;
|
||||
z-index: 950;
|
||||
width: 340px;
|
||||
max-width: calc(100vw - 32px);
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-xl, 12px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||
animation: tour-card-enter 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes tour-card-enter {
|
||||
from { opacity: 0; transform: translateY(16px) scale(0.95); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.tour-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 12px 8px;
|
||||
}
|
||||
|
||||
.tour-card__mascot {
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--color-brand-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tour-card__meta {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.tour-card__tour-title {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.tour-card__step-count {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.tour-card__close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.tour-card__body {
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.tour-card__title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-heading);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tour-card__text {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.55;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tour-card__action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--color-brand-primary);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
background: transparent;
|
||||
color: var(--color-brand-primary);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
}
|
||||
|
||||
.tour-card__progress {
|
||||
height: 3px;
|
||||
background: var(--color-surface-tertiary);
|
||||
}
|
||||
|
||||
.tour-card__progress-fill {
|
||||
height: 100%;
|
||||
background: var(--color-brand-primary);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.tour-card__nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.tour-card__nav-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--color-brand-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.tour-card__nav-btn--primary {
|
||||
background: var(--color-brand-primary);
|
||||
border-color: var(--color-brand-primary);
|
||||
color: var(--color-text-heading);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tour-backdrop, .tour-card { animation: none; }
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class StellaTourComponent implements OnDestroy {
|
||||
private readonly router = inject(Router);
|
||||
private readonly assistant = inject(StellaAssistantService);
|
||||
|
||||
// ---- State ----
|
||||
readonly activeTour = signal<Tour | null>(null);
|
||||
readonly currentStepIndex = signal(0);
|
||||
private resizeObserver?: ResizeObserver;
|
||||
|
||||
// ---- Derived ----
|
||||
readonly isActive = computed(() => this.activeTour() !== null);
|
||||
readonly tourTitle = computed(() => this.activeTour()?.title ?? '');
|
||||
readonly totalSteps = computed(() => this.activeTour()?.steps.length ?? 0);
|
||||
readonly currentStep = computed<TourStep | null>(() => {
|
||||
const tour = this.activeTour();
|
||||
if (!tour) return null;
|
||||
return tour.steps[this.currentStepIndex()] ?? null;
|
||||
});
|
||||
readonly progressPercent = computed(() => {
|
||||
const total = this.totalSteps();
|
||||
return total > 0 ? ((this.currentStepIndex() + 1) / total) * 100 : 0;
|
||||
});
|
||||
|
||||
readonly highlightRect = signal<DOMRect | null>(null);
|
||||
readonly cardPosition = computed(() => {
|
||||
const rect = this.highlightRect();
|
||||
if (!rect) {
|
||||
// Center the card
|
||||
return { top: window.innerHeight / 2 - 120, left: window.innerWidth / 2 - 170 };
|
||||
}
|
||||
// Position card below or beside the highlighted element
|
||||
const cardWidth = 340;
|
||||
const cardHeight = 240;
|
||||
let top = rect.bottom + 16;
|
||||
let left = rect.left;
|
||||
|
||||
// If card would go below viewport, place it above
|
||||
if (top + cardHeight > window.innerHeight - 16) {
|
||||
top = rect.top - cardHeight - 16;
|
||||
}
|
||||
// Keep within horizontal bounds
|
||||
if (left + cardWidth > window.innerWidth - 16) {
|
||||
left = window.innerWidth - cardWidth - 16;
|
||||
}
|
||||
if (left < 16) left = 16;
|
||||
if (top < 16) top = 16;
|
||||
|
||||
return { top, left };
|
||||
});
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.resizeObserver?.disconnect();
|
||||
}
|
||||
|
||||
// ---- Public API ----
|
||||
|
||||
startTour(tour: Tour): void {
|
||||
this.activeTour.set(tour);
|
||||
this.currentStepIndex.set(0);
|
||||
this.navigateToStep(0);
|
||||
}
|
||||
|
||||
// ---- Handlers ----
|
||||
|
||||
onNext(): void {
|
||||
const nextIdx = this.currentStepIndex() + 1;
|
||||
if (nextIdx < this.totalSteps()) {
|
||||
this.currentStepIndex.set(nextIdx);
|
||||
this.navigateToStep(nextIdx);
|
||||
}
|
||||
}
|
||||
|
||||
onPrev(): void {
|
||||
const prevIdx = this.currentStepIndex() - 1;
|
||||
if (prevIdx >= 0) {
|
||||
this.currentStepIndex.set(prevIdx);
|
||||
this.navigateToStep(prevIdx);
|
||||
}
|
||||
}
|
||||
|
||||
onSkip(): void {
|
||||
this.endTour(false);
|
||||
}
|
||||
|
||||
onFinish(): void {
|
||||
this.endTour(true);
|
||||
}
|
||||
|
||||
onStepAction(route: string): void {
|
||||
this.router.navigateByUrl(route);
|
||||
}
|
||||
|
||||
// ---- Internal ----
|
||||
|
||||
private navigateToStep(idx: number): void {
|
||||
const step = this.activeTour()?.steps[idx];
|
||||
if (!step) return;
|
||||
|
||||
// Navigate to the step's route if different from current
|
||||
const currentPath = window.location.pathname;
|
||||
if (step.route && step.route !== currentPath) {
|
||||
this.router.navigateByUrl(step.route).then(() => {
|
||||
setTimeout(() => this.highlightElement(step.selector), 500);
|
||||
});
|
||||
} else {
|
||||
this.highlightElement(step.selector);
|
||||
}
|
||||
}
|
||||
|
||||
private highlightElement(selector?: string): void {
|
||||
if (!selector) {
|
||||
this.highlightRect.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const el = document.querySelector(selector);
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
this.highlightRect.set(rect);
|
||||
|
||||
// Scroll element into view if needed
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
} else {
|
||||
this.highlightRect.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
private endTour(completed: boolean): void {
|
||||
const tour = this.activeTour();
|
||||
if (tour && completed) {
|
||||
this.assistant.userState.update(s => ({
|
||||
...s,
|
||||
completedTours: s.completedTours.includes(tour.tourKey)
|
||||
? s.completedTours
|
||||
: [...s.completedTours, tour.tourKey],
|
||||
}));
|
||||
}
|
||||
this.activeTour.set(null);
|
||||
this.highlightRect.set(null);
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,27 @@
|
||||
* Gentle, unobtrusive navigational quick-links rendered as subtle inline text
|
||||
* with arrow indicators. Replaces all pill/card-based quick-link patterns.
|
||||
*
|
||||
* Layouts:
|
||||
* - 'inline' — horizontal dots (minimal, for contextual links)
|
||||
* - 'aside' — vertical list with descriptions (sidebar/bottom positions)
|
||||
* - 'strip' — horizontal header strip with title + description, right-fade
|
||||
* gradient and scroll button when overflowing (canonical header pattern)
|
||||
*
|
||||
* Usage:
|
||||
* <stella-quick-links [links]="links" label="Related" />
|
||||
* <stella-quick-links [links]="links" label="Quick Links" layout="strip" />
|
||||
*/
|
||||
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
Input,
|
||||
NgZone,
|
||||
OnDestroy,
|
||||
ViewChild,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
@@ -25,7 +38,7 @@ export interface StellaQuickLink {
|
||||
icon?: string;
|
||||
/** Tooltip text */
|
||||
hint?: string;
|
||||
/** Short description shown below the label in aside layout */
|
||||
/** Short description shown below the label in aside/strip layout */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -35,10 +48,56 @@ export interface StellaQuickLink {
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
@if (label) {
|
||||
@if (label && layout !== 'strip') {
|
||||
<span class="sql-label">{{ label }}</span>
|
||||
}
|
||||
@if (layout === 'aside') {
|
||||
@if (layout === 'strip') {
|
||||
<div class="sql-strip-outer">
|
||||
@if (label) {
|
||||
<span class="sql-strip-label">{{ label }}:</span>
|
||||
}
|
||||
<div class="sql-strip-scroll-area">
|
||||
@if (canScrollLeft()) {
|
||||
<div class="sql-strip-fade sql-strip-fade--left"></div>
|
||||
<button class="sql-strip-btn sql-strip-btn--left"
|
||||
(mouseenter)="startAutoScroll('left')"
|
||||
(mouseleave)="stopAutoScroll()"
|
||||
(click)="jumpScroll('left')"
|
||||
type="button" aria-label="Scroll left">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 18l-6-6 6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
<nav class="sql-strip" #stripNav [attr.aria-label]="label || 'Quick links'">
|
||||
@for (link of links; track link.label) {
|
||||
<a class="sql-strip-link" [routerLink]="link.route" [title]="link.hint || link.label">
|
||||
<span class="sql-strip-title">{{ link.label }}</span>
|
||||
@if (link.description) {
|
||||
<span class="sql-strip-desc">{{ link.description }}</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
@if (canScrollRight()) {
|
||||
<div class="sql-strip-fade sql-strip-fade--right"></div>
|
||||
<button class="sql-strip-btn sql-strip-btn--right"
|
||||
(mouseenter)="startAutoScroll('right')"
|
||||
(mouseleave)="stopAutoScroll()"
|
||||
(click)="jumpScroll('right')"
|
||||
type="button" aria-label="Scroll right">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 6l6 6-6 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else if (layout === 'aside') {
|
||||
<nav class="sql-aside" [attr.aria-label]="label || 'Quick links'">
|
||||
@for (link of links; track link.label) {
|
||||
<a class="sql-aside-link" [routerLink]="link.route" [title]="link.hint || link.label">
|
||||
@@ -182,7 +241,160 @@ export interface StellaQuickLink {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Aside layout — vertical list with descriptions */
|
||||
/* ====== Strip layout — canonical header pattern ====== */
|
||||
:host([layout=strip]) {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.sql-strip-outer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sql-strip-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-secondary);
|
||||
opacity: 0.55;
|
||||
padding-right: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sql-strip-scroll-area {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
--_fade-color: var(--strip-fade-color, var(--color-surface-primary));
|
||||
}
|
||||
|
||||
.sql-strip {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
align-items: stretch;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.sql-strip::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sql-strip-fade {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 48px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sql-strip-fade--left {
|
||||
left: 0;
|
||||
background: linear-gradient(to right, var(--_fade-color) 30%, transparent 100%);
|
||||
}
|
||||
|
||||
.sql-strip-fade--right {
|
||||
right: 0;
|
||||
background: linear-gradient(to left, var(--_fade-color) 30%, transparent 100%);
|
||||
}
|
||||
|
||||
.sql-strip-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: var(--radius-full, 9999px);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: var(--color-surface-primary);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background 120ms ease, color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.sql-strip-btn:hover {
|
||||
background: var(--color-surface-elevated, var(--color-surface-secondary));
|
||||
color: var(--color-text-primary);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.sql-strip-btn--left { left: 0; }
|
||||
.sql-strip-btn--right { right: 0; }
|
||||
|
||||
.sql-strip-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 0.25rem 1rem;
|
||||
text-decoration: none;
|
||||
color: var(--color-text-secondary);
|
||||
border-right: 1px solid color-mix(in srgb, var(--color-border-primary) 35%, transparent);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: color 120ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sql-strip-link:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.sql-strip-link:last-child {
|
||||
border-right: none;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.sql-strip-link:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.sql-strip-link:hover .sql-strip-title {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--color-brand-primary);
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
.sql-strip-link:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.sql-strip-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.sql-strip-desc {
|
||||
font-size: 0.625rem;
|
||||
line-height: 1.3;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.sql-strip-link:hover .sql-strip-desc {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ====== Aside layout — vertical list with descriptions ====== */
|
||||
:host([layout=aside]) {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
@@ -261,13 +473,89 @@ export interface StellaQuickLink {
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class StellaQuickLinksComponent {
|
||||
/** Array of quick link definitions. */
|
||||
export class StellaQuickLinksComponent implements AfterViewInit, OnDestroy {
|
||||
private readonly zone = inject(NgZone);
|
||||
|
||||
@Input() links: StellaQuickLink[] = [];
|
||||
|
||||
/** Optional heading label (e.g. "Related", "Shortcuts", "Jump to"). */
|
||||
@Input() label?: string;
|
||||
@Input() layout: 'inline' | 'aside' | 'strip' = 'inline';
|
||||
|
||||
/** Layout variant: 'inline' (horizontal dots) or 'aside' (vertical with descriptions). */
|
||||
@Input() layout: 'inline' | 'aside' = 'inline';
|
||||
@ViewChild('stripNav') stripNav?: ElementRef<HTMLElement>;
|
||||
|
||||
readonly canScrollLeft = signal(false);
|
||||
readonly canScrollRight = signal(false);
|
||||
|
||||
private resizeObserver?: ResizeObserver;
|
||||
private autoScrollRaf = 0;
|
||||
private autoScrollDir: 'left' | 'right' | null = null;
|
||||
private static readonly AUTO_SPEED = 1.5; // px per frame (~90px/s at 60fps)
|
||||
private static readonly JUMP_PX = 220;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
if (this.layout === 'strip' && this.stripNav) {
|
||||
this.syncOverflow();
|
||||
this.zone.runOutsideAngular(() => {
|
||||
this.stripNav!.nativeElement.addEventListener('scroll', this.onScroll, { passive: true });
|
||||
this.resizeObserver = new ResizeObserver(() => this.syncOverflow());
|
||||
this.resizeObserver.observe(this.stripNav!.nativeElement);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopAutoScroll();
|
||||
this.stripNav?.nativeElement.removeEventListener('scroll', this.onScroll);
|
||||
this.resizeObserver?.disconnect();
|
||||
}
|
||||
|
||||
/** Hover: start continuous animated scroll */
|
||||
startAutoScroll(dir: 'left' | 'right'): void {
|
||||
this.autoScrollDir = dir;
|
||||
if (!this.autoScrollRaf) {
|
||||
this.zone.runOutsideAngular(() => this.tick());
|
||||
}
|
||||
}
|
||||
|
||||
/** Leave: stop animation */
|
||||
stopAutoScroll(): void {
|
||||
this.autoScrollDir = null;
|
||||
if (this.autoScrollRaf) {
|
||||
cancelAnimationFrame(this.autoScrollRaf);
|
||||
this.autoScrollRaf = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Click: jump scroll, stop animation, then re-hover resumes */
|
||||
jumpScroll(dir: 'left' | 'right'): void {
|
||||
const el = this.stripNav?.nativeElement;
|
||||
if (!el) return;
|
||||
this.stopAutoScroll();
|
||||
const px = dir === 'right' ? StellaQuickLinksComponent.JUMP_PX : -StellaQuickLinksComponent.JUMP_PX;
|
||||
el.scrollBy({ left: px, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
private tick = (): void => {
|
||||
const el = this.stripNav?.nativeElement;
|
||||
if (!el || !this.autoScrollDir) { this.autoScrollRaf = 0; return; }
|
||||
const delta = this.autoScrollDir === 'right'
|
||||
? StellaQuickLinksComponent.AUTO_SPEED
|
||||
: -StellaQuickLinksComponent.AUTO_SPEED;
|
||||
el.scrollLeft += delta;
|
||||
this.autoScrollRaf = requestAnimationFrame(this.tick);
|
||||
};
|
||||
|
||||
private onScroll = (): void => { this.syncOverflow(); };
|
||||
|
||||
private syncOverflow(): void {
|
||||
const el = this.stripNav?.nativeElement;
|
||||
if (!el) return;
|
||||
const left = el.scrollLeft > 4;
|
||||
const right = el.scrollWidth - el.scrollLeft - el.clientWidth > 4;
|
||||
if (left !== this.canScrollLeft() || right !== this.canScrollRight()) {
|
||||
this.zone.run(() => {
|
||||
this.canScrollLeft.set(left);
|
||||
this.canScrollRight.set(right);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
Directive,
|
||||
ElementRef,
|
||||
inject,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
Renderer2,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { StellaAssistantService } from '../components/stella-helper/stella-assistant.service';
|
||||
|
||||
/**
|
||||
* StellaGlossaryDirective — Auto-annotates domain terms with hover tooltips.
|
||||
*
|
||||
* Usage:
|
||||
* <p stellaGlossary>This SBOM contains VEX statements about CVE findings.</p>
|
||||
*
|
||||
* The directive scans the element's text content for known glossary terms
|
||||
* (loaded from DB via StellaAssistantService.glossary()) and wraps the
|
||||
* first occurrence of each term with a tooltip.
|
||||
*
|
||||
* Only annotates the FIRST occurrence per term per element to avoid
|
||||
* visual noise.
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[stellaGlossary]',
|
||||
standalone: true,
|
||||
})
|
||||
export class StellaGlossaryDirective implements OnInit, OnDestroy {
|
||||
private readonly el = inject(ElementRef);
|
||||
private readonly renderer = inject(Renderer2);
|
||||
private readonly assistant = inject(StellaAssistantService);
|
||||
|
||||
private processed = false;
|
||||
private originalHtml = '';
|
||||
private tooltipEl: HTMLElement | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
// Wait a tick for the element to render, then annotate
|
||||
setTimeout(() => this.annotate(), 500);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.removeTooltip();
|
||||
}
|
||||
|
||||
private annotate(): void {
|
||||
if (this.processed) return;
|
||||
|
||||
const terms = this.assistant.glossary();
|
||||
if (terms.length === 0) return;
|
||||
|
||||
const element = this.el.nativeElement as HTMLElement;
|
||||
this.originalHtml = element.innerHTML;
|
||||
|
||||
// Build a regex for all terms (case-insensitive, word boundaries)
|
||||
// Sort by length descending to match longer terms first
|
||||
const sortedTerms = [...terms].sort((a, b) => b.term.length - a.term.length);
|
||||
const annotatedTerms = new Set<string>();
|
||||
|
||||
let html = this.originalHtml;
|
||||
|
||||
for (const term of sortedTerms) {
|
||||
if (annotatedTerms.has(term.term.toLowerCase())) continue;
|
||||
|
||||
// Match only in text nodes (not inside tags)
|
||||
const termRegex = new RegExp(
|
||||
`(?<![<\\w])\\b(${escapeRegex(term.term)})\\b(?![\\w>])`,
|
||||
'i'
|
||||
);
|
||||
|
||||
if (termRegex.test(html)) {
|
||||
html = html.replace(termRegex, (match) => {
|
||||
annotatedTerms.add(term.term.toLowerCase());
|
||||
return `<span class="stella-glossary-term" data-term="${escapeHtml(term.term)}" data-def="${escapeHtml(term.definition)}">${match}</span>`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (annotatedTerms.size > 0) {
|
||||
element.innerHTML = html;
|
||||
this.processed = true;
|
||||
|
||||
// Add event listeners for tooltips
|
||||
const termEls = element.querySelectorAll('.stella-glossary-term');
|
||||
termEls.forEach((el) => {
|
||||
this.renderer.listen(el, 'mouseenter', (e: MouseEvent) => this.showTooltip(e, el as HTMLElement));
|
||||
this.renderer.listen(el, 'mouseleave', () => this.removeTooltip());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private showTooltip(event: MouseEvent, target: HTMLElement): void {
|
||||
this.removeTooltip();
|
||||
|
||||
const def = target.getAttribute('data-def') ?? '';
|
||||
const term = target.getAttribute('data-term') ?? '';
|
||||
|
||||
this.tooltipEl = this.renderer.createElement('div');
|
||||
this.tooltipEl!.className = 'stella-glossary-tooltip';
|
||||
this.tooltipEl!.innerHTML = `<strong>${escapeHtml(term)}</strong><br>${escapeHtml(def)}`;
|
||||
|
||||
document.body.appendChild(this.tooltipEl!);
|
||||
|
||||
// Position above the term
|
||||
const rect = target.getBoundingClientRect();
|
||||
const tipRect = this.tooltipEl!.getBoundingClientRect();
|
||||
const left = Math.max(8, Math.min(rect.left + rect.width / 2 - tipRect.width / 2, window.innerWidth - tipRect.width - 8));
|
||||
const top = rect.top - tipRect.height - 8;
|
||||
|
||||
this.tooltipEl!.style.left = `${left}px`;
|
||||
this.tooltipEl!.style.top = `${top > 8 ? top : rect.bottom + 8}px`;
|
||||
}
|
||||
|
||||
private removeTooltip(): void {
|
||||
if (this.tooltipEl) {
|
||||
this.tooltipEl.remove();
|
||||
this.tooltipEl = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
Reference in New Issue
Block a user