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:
master
2026-03-30 17:24:39 +03:00
parent ae5059aa1c
commit 8931fc7c0c
21 changed files with 9649 additions and 324 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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 ---- */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}