feat(intelligence): expose durable refresh progress

This commit is contained in:
joshpatra committed 2026-07-27 05:29:45 -04:00
1 parent 6a85ddd1c5
commit c5fc89f31c
7 files changed
+154 -14

No files matched your search

@@ -146,6 +146,7 @@ public sealed class IntelligenceControllerTests : IAsyncLifetime
ScopeKey = $"{_tenant:N}:{_user:N}",
TenantId = _tenant,
OwnerUserId = _user,
LibraryScopeId = "music",
Type = "recommendation.generate",
PayloadJson = "{}",
PolicySnapshotJson = "{}",
@@ -158,6 +159,18 @@ public sealed class IntelligenceControllerTests : IAsyncLifetime
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
db.AuditEvents.Add(new()
{
Id = Guid.CreateVersion7(),
TenantId = _tenant,
ActorUserId = _user,
Category = "job-progress",
Action = "recommendation.rank",
Outcome = "running",
CorrelationId = "test",
DetailsJson = """{"stage":"recommendation.rank","message":"Ranking tracks.","completed":1,"total":2}""",
CreatedAt = DateTimeOffset.UtcNow
});
db.RecommendationRuns.Add(new()
{
Id = run,
@@ -228,6 +241,7 @@ public sealed class IntelligenceControllerTests : IAsyncLifetime
Assert.Contains("lastfm:fixture", json, StringComparison.Ordinal);
Assert.Contains("Recommended song", json, StringComparison.Ordinal);
Assert.Contains("\"latestRunState\":\"succeeded\"", json, StringComparison.Ordinal);
Assert.Contains("Ranking tracks.", json, StringComparison.Ordinal);
Assert.DoesNotContain("TenantId", json, StringComparison.Ordinal);
var feedback = Assert.IsType<OkObjectResult>(await Controller().SetFeedback(
Guid.Parse("33333333-3333-3333-3333-333333333333"), new()
+45 -1
View File
@@ -63,8 +63,14 @@ public sealed class IntelligenceCoreTests : IAsyncLifetime
var claim = await _jobs.ClaimNextAsync("intelligence-restart", ["recommendation.generate"]); Assert.NotNull(claim);
var handler = new RecommendationRunJobHandler(_factory, [new FixtureProvider()],
new ListeningProfileService(_factory, _clock), _clock);
var completion = await handler.ExecuteAsync(new(claim!, EmptyServices.Instance), default);
var progress = new List<DurableJobProgressUpdate>();
var completion = await handler.ExecuteAsync(new(claim!, EmptyServices.Instance)
{
ReportProgressAsync = (update, _) => { progress.Add(update); return Task.FromResult(true); }
}, default);
await _jobs.CompleteAsync(claim!, completion); Assert.Equal(DurableJobCompletionKind.Succeeded, completion.Kind);
Assert.Equal(["recommendation.profile", "recommendation.provider", "recommendation.provider",
"recommendation.rank", "recommendation.complete"], progress.Select(item => item.Stage));
await using var db = await _factory.CreateDbContextAsync(); var candidate = Assert.Single(await db.RecommendationCandidates.ToListAsync());
Assert.Equal("track-1", candidate.TrackKey); Assert.Contains("shared-genre", candidate.SignalsJson, StringComparison.Ordinal);
Assert.Equal(Guid.Parse("11111111-1111-1111-1111-111111111111"), candidate.CanonicalRecordingId);
@@ -269,6 +275,33 @@ public sealed class IntelligenceCoreTests : IAsyncLifetime
Assert.NotNull(run.CompletedAt);
}
[Fact]
public async Task TransientProviderFailureUsesDurableRetryAndThenCompletes()
{
await _policies.SetAsync(_scope, new(true, 30, ["play"], ["flaky"]));
await new RecommendationRunService(_factory, _jobs, _clock).EnqueueAsync(
_scope, [], 10, "retry-run");
var provider = new FailOnceProvider();
var handler = new RecommendationRunJobHandler(_factory, [provider],
new ListeningProfileService(_factory, _clock), _clock);
var firstClaim = await _jobs.ClaimNextAsync("retry-worker", ["recommendation.generate"]);
var first = await handler.ExecuteAsync(new(firstClaim!, EmptyServices.Instance), default);
await _jobs.CompleteAsync(firstClaim!, first);
Assert.Equal(DurableJobCompletionKind.Retry, first.Kind);
await using (var failed = await _factory.CreateDbContextAsync())
Assert.Equal(RecommendationRunState.Failed,
(await failed.RecommendationRuns.SingleAsync()).State);
_clock.UtcNow = _clock.UtcNow.AddMinutes(1);
var retryClaim = await _jobs.ClaimNextAsync("retry-worker", ["recommendation.generate"]);
var retry = await handler.ExecuteAsync(new(retryClaim!, EmptyServices.Instance), default);
await _jobs.CompleteAsync(retryClaim!, retry);
Assert.Equal(DurableJobCompletionKind.Succeeded, retry.Kind);
await using var completed = await _factory.CreateDbContextAsync();
Assert.Equal(RecommendationRunState.Succeeded,
(await completed.RecommendationRuns.SingleAsync()).State);
}
[Fact]
public async Task EmptySeedRunUsesWeightedExactScopeListeningHabits()
{
@@ -315,6 +348,17 @@ public sealed class IntelligenceCoreTests : IAsyncLifetime
public Task<RecommendationProviderResult> RecommendAsync(RecommendationRequest request)
{ Seeds = request.SeedTrackKeys; return Task.FromResult(new RecommendationProviderResult(RecommendationProviderState.Succeeded, [])); }
}
private sealed class FailOnceProvider : IRecommendationProvider
{
private bool _failed;
public string Id => "flaky";
public Task<RecommendationProviderResult> RecommendAsync(RecommendationRequest request)
{
if (!_failed) { _failed = true; throw new HttpRequestException(); }
return Task.FromResult(new RecommendationProviderResult(
RecommendationProviderState.Succeeded, []));
}
}
private sealed class Clock(DateTimeOffset now) : IPlatformClock { public DateTimeOffset UtcNow { get; set; } = now; }
private sealed class Factory(DbContextOptions<AllstarrDbContext> options) : IDbContextFactory<AllstarrDbContext>
{ public AllstarrDbContext CreateDbContext() => new(options); public Task<AllstarrDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) => Task.FromResult(CreateDbContext()); }
+19 -1
View File
@@ -40,6 +40,15 @@ public sealed class IntelligenceController(
item.OwnerUserId == scope.OwnerUserId && item.Protocol == scope.Protocol &&
item.BackendInstanceId == scope.BackendInstanceId && item.LibraryScopeId == scope.LibraryScopeId)
.OrderByDescending(item => item.CreatedAt).FirstOrDefaultAsync(cancellationToken);
var latestJob = latestRun == null ? null : await db.Jobs.AsNoTracking().SingleOrDefaultAsync(item =>
item.Id == latestRun.JobId && item.TenantId == scope.TenantId &&
item.OwnerUserId == scope.OwnerUserId && item.LibraryScopeId == scope.LibraryScopeId,
cancellationToken);
var latestProgress = latestJob == null ? null : await db.AuditEvents.AsNoTracking()
.Where(item => item.Category == "job-progress" &&
item.CorrelationId == latestJob.CorrelationId)
.OrderByDescending(item => item.CreatedAt).Select(item => item.DetailsJson)
.FirstOrDefaultAsync(cancellationToken);
var candidates = latestRun == null ? [] : await db.RecommendationCandidates.AsNoTracking()
.Where(item => item.RunId == latestRun.Id && item.TenantId == scope.TenantId && item.OwnerUserId == scope.OwnerUserId)
.OrderBy(item => item.Position).Take(100).ToListAsync(cancellationToken);
@@ -116,7 +125,16 @@ public sealed class IntelligenceController(
canRun = policy?.Enabled == true && enabledProviders.Any(id => readinessById.TryGetValue(id, out var item) && item.State == RecommendationProviderReadinessState.Ready),
canGenerate = latestRun?.State == RecommendationRunState.Succeeded,
latestRunId = latestRun?.Id,
latestRunState = latestRun?.State.ToString().ToLowerInvariant()
latestRunState = latestJob?.State.ToString().ToLowerInvariant() ??
latestRun?.State.ToString().ToLowerInvariant(),
latestJobId = latestJob?.Id,
latestJob?.AttemptCount,
latestJob?.FailureCount,
latestJob?.MaxAttempts,
canCancel = latestJob?.State is DurableJobState.Pending or DurableJobState.RetryScheduled
or DurableJobState.Running,
progress = latestProgress == null ? (JsonElement?)null :
JsonSerializer.Deserialize<JsonElement>(latestProgress)
},
candidates = candidates.Select(item =>
{
@@ -171,12 +171,22 @@ public sealed class RecommendationRunJobHandler(IDbContextFactory<AllstarrDbCont
if (run.State == RecommendationRunState.Succeeded) return await EnsureScheduledSetAsync(db, run, snapshot.Automation, cancellationToken);
var scope = new IntelligenceScope(run.TenantId, run.OwnerUserId, run.Protocol, run.BackendInstanceId, run.LibraryScopeId);
var policy = await IntelligencePolicyService.Query(db, scope).AsNoTracking().SingleOrDefaultAsync(cancellationToken); if (policy?.Enabled != true) { run.State = RecommendationRunState.Cancelled; run.CompletedAt = clock.UtcNow; await db.SaveChangesAsync(cancellationToken); return DurableJobCompletion.Cancelled(); }
var enabled = snapshot.EnabledProviders.ToHashSet(StringComparer.Ordinal); run.State = RecommendationRunState.Running; run.UpdatedAt = clock.UtcNow; run.Revision++; await db.SaveChangesAsync(cancellationToken);
var enabled = snapshot.EnabledProviders.ToHashSet(StringComparer.Ordinal);
var selectedProviders = providers.Where(item => enabled.Contains(item.Id))
.OrderBy(item => item.Id, StringComparer.Ordinal).ToArray();
run.State = RecommendationRunState.Running; run.CompletedAt = null; run.ErrorCode = null;
run.UpdatedAt = clock.UtcNow; run.Revision++; await db.SaveChangesAsync(cancellationToken);
await execution.ReportProgressAsync(new("recommendation.profile",
"Building the retained listening profile.", 0, selectedProviders.Length), cancellationToken);
var profile = await profiles.BuildAsync(scope, cancellationToken); var seeds = JsonSerializer.Deserialize<string[]>(run.SeedTrackKeysJson) ?? [];
if (seeds.Length == 0) seeds = profile.TopTrackKeys.ToArray();
var results = new List<RecommendationCandidate>();
foreach (var provider in providers.Where(x => enabled.Contains(x.Id)))
for (var providerIndex = 0; providerIndex < selectedProviders.Length; providerIndex++)
{
var provider = selectedProviders[providerIndex];
await execution.ReportProgressAsync(new("recommendation.provider",
$"Searching {provider.Id}.", providerIndex, selectedProviders.Length,
Provider: provider.Id), cancellationToken);
RecommendationProviderResult outcome; try { outcome = await provider.RecommendAsync(new(scope, run.Id, profile, seeds, run.Limit, run.IdempotencyKey, true, cancellationToken)); }
catch (OperationCanceledException)
{
@@ -185,8 +195,18 @@ public sealed class RecommendationRunJobHandler(IDbContextFactory<AllstarrDbCont
await db.SaveChangesAsync(CancellationToken.None);
return DurableJobCompletion.Cancelled();
}
catch { return DurableJobCompletion.Retry("recommendation_provider_temporary_failure", "A recommendation provider temporarily failed."); }
if (outcome.State == RecommendationProviderState.Succeeded) results.AddRange(outcome.Candidates);
catch
{
run.State = RecommendationRunState.Failed; run.ErrorCode = "recommendation_provider_temporary_failure";
run.CompletedAt = clock.UtcNow; run.UpdatedAt = clock.UtcNow; run.Revision++;
await db.SaveChangesAsync(CancellationToken.None);
return DurableJobCompletion.Retry("recommendation_provider_temporary_failure", "A recommendation provider temporarily failed.");
}
if (outcome.State == RecommendationProviderState.Succeeded)
results.AddRange(outcome.Candidates.Take(run.Limit * 4));
await execution.ReportProgressAsync(new("recommendation.provider",
$"Finished {provider.Id}.", providerIndex + 1, selectedProviders.Length,
Provider: provider.Id), cancellationToken);
}
var excludedTrackKeys = await db.RecommendationFeedback.AsNoTracking().Where(item =>
item.TenantId == run.TenantId && item.OwnerUserId == run.OwnerUserId &&
@@ -201,11 +221,15 @@ public sealed class RecommendationRunJobHandler(IDbContextFactory<AllstarrDbCont
var ordered = deduplicated.Where(item => !excluded.Contains(item.TrackKey)).Take(run.Limit)
.Concat(deduplicated.Where(item => excluded.Contains(item.TrackKey)).Take(Math.Min(100, run.Limit))
.Select(item => item with { Exclusions = ["user-feedback"] })).ToArray();
await execution.ReportProgressAsync(new("recommendation.rank",
$"Ranking {ordered.Length} candidate tracks.", 0, ordered.Length), cancellationToken);
ordered = await ResolveProvenanceAsync(db, run, ordered, cancellationToken);
db.RecommendationCandidates.RemoveRange(db.RecommendationCandidates.Where(x => x.RunId == run.Id));
for (var i = 0; i < ordered.Length; i++) db.RecommendationCandidates.Add(new() { Id = Guid.CreateVersion7(), RunId = run.Id, TenantId = run.TenantId, OwnerUserId = run.OwnerUserId, Position = i, TrackKey = ordered[i].TrackKey, Score = ordered[i].Score, Source = ordered[i].Source, SignalsJson = JsonSerializer.Serialize(ordered[i].Signals), IdentityJson = JsonSerializer.Serialize(ordered[i].Identity), CanonicalRecordingId = ordered[i].CanonicalRecordingId, ProviderAccountId = ordered[i].ProviderAccountId, SourceRevision = ordered[i].SourceRevision ?? $"run:{run.Id:N}", ExclusionsJson = JsonSerializer.Serialize(ordered[i].Exclusions), CreatedAt = clock.UtcNow, Revision = 1 });
run.State = RecommendationRunState.Succeeded; run.CompletedAt = clock.UtcNow; run.UpdatedAt = clock.UtcNow; run.Revision++;
await db.SaveChangesAsync(cancellationToken);
await execution.ReportProgressAsync(new("recommendation.complete",
$"Saved {ordered.Length} recommendation tracks.", ordered.Length, ordered.Length), cancellationToken);
return await EnsureScheduledSetAsync(db, run, snapshot.Automation, cancellationToken);
}
private async Task<DurableJobCompletion> EnsureScheduledSetAsync(AllstarrDbContext db,
@@ -340,6 +364,8 @@ public sealed class GeneratedSetMaterializationJobHandler(IDbContextFactory<Alls
var candidates = entries.Select(x => new RecommendationCandidate(x.TrackKey, x.Score, x.Source,
JsonSerializer.Deserialize<RecommendationSignal[]>(x.ExplanationJson) ?? [],
JsonSerializer.Deserialize<RecommendationTrackIdentity>(x.IdentityJson))).ToArray();
await execution.ReportProgressAsync(new("playlist.materialize",
$"Creating {set.Name}.", 0, candidates.Length, Playlist: set.Name), cancellationToken);
GeneratedSetMaterializationResult result;
try
{
@@ -352,7 +378,7 @@ public sealed class GeneratedSetMaterializationJobHandler(IDbContextFactory<Alls
set.UpdatedAt = Now; set.Revision++; await db.SaveChangesAsync(CancellationToken.None);
return DurableJobCompletion.Cancelled();
}
if (result.Succeeded) { set.MaterializationState = GeneratedSetMaterializationState.Succeeded; set.BackendPlaylistId = result.BackendPlaylistId; set.TargetRevision = result.TargetRevision; set.MaterializedAt = Now; set.UpdatedAt = Now; set.Revision++; await db.SaveChangesAsync(cancellationToken); return DurableJobCompletion.Success(); }
if (result.Succeeded) { set.MaterializationState = GeneratedSetMaterializationState.Succeeded; set.BackendPlaylistId = result.BackendPlaylistId; set.TargetRevision = result.TargetRevision; set.MaterializedAt = Now; set.UpdatedAt = Now; set.Revision++; await db.SaveChangesAsync(cancellationToken); await execution.ReportProgressAsync(new("playlist.materialize", $"Created {set.Name}.", candidates.Length, candidates.Length, Playlist: set.Name), cancellationToken); return DurableJobCompletion.Success(); }
set.MaterializationState = GeneratedSetMaterializationState.Failed; set.LastErrorCode = result.SafeErrorCode ?? "generated_set_failed"; set.UpdatedAt = Now; set.Revision++; await db.SaveChangesAsync(cancellationToken);
return result.Retryable ? DurableJobCompletion.Retry(result.SafeErrorCode ?? "generated_set_retry", "Generated playlist materialization will retry.")
: DurableJobCompletion.Failure(result.SafeErrorCode ?? "generated_set_failed", "Generated playlist materialization failed.");
+14
View File
@@ -564,6 +564,20 @@ export type IntelligenceState = {
canGenerate: boolean;
latestRunId?: string | null;
latestRunState?: string | null;
latestJobId?: string | null;
attemptCount?: number | null;
failureCount?: number | null;
maxAttempts?: number | null;
canCancel?: boolean;
progress?: {
stage: string;
message: string;
completed?: number | null;
total?: number | null;
provider?: string | null;
playlist?: string | null;
track?: string | null;
} | null;
};
candidates: Array<{
id: string;
@@ -1,6 +1,6 @@
<script lang="ts">
import { AlertDialog } from "bits-ui";
import { intelligence, type IntelligenceScope, type IntelligenceState } from "$lib/api";
import { home, intelligence, type IntelligenceScope, type IntelligenceState } from "$lib/api";
let protocol = $state("jellyfin");
let backendInstanceId = $state("");
@@ -18,6 +18,13 @@
const scope = $derived<IntelligenceScope>({ protocol, backendInstanceId, libraryScopeId });
const visibleCandidates = $derived(data?.candidates.filter((item) => !item.exclusions.length) ?? []);
const runState = $derived(data?.actions.latestRunState?.replace("retryscheduled", "retry scheduled"));
$effect(() => {
if (!["pending", "running", "retry scheduled"].includes(runState ?? "")) return;
const timer = setTimeout(() => void load(), 1500);
return () => clearTimeout(timer);
});
function adopt(next: IntelligenceState) {
data = next;
@@ -69,7 +76,7 @@
</div>
{#if data?.actions.canRun}
<div class="heading-actions">
{#if data.actions.latestRunState}<span class={`status-pill ${data.actions.latestRunState === "succeeded" ? "healthy" : "suggested"}`}>{data.actions.latestRunState}</span>{/if}
{#if runState}<span class={`status-pill ${runState === "succeeded" ? "healthy" : "suggested"}`}>{runState}</span>{/if}
<button class="button-primary" type="button" disabled={Boolean(action)}
onclick={() => void perform("run", () => intelligence.run(scope))}>
{action === "run" ? "Starting…" : "Refresh recommendations"}
@@ -107,6 +114,16 @@
{#if data.state === "degraded"}
<div class="degraded-banner" role="status"><span aria-hidden="true">!</span><p><strong>Some discovery sources need attention.</strong> Existing results remain available.</p></div>
{/if}
{#if data.actions.progress}
<section class="panel run-progress" role="status">
<div><p class="eyebrow">{data.actions.progress.stage}</p><strong>{data.actions.progress.message}</strong>
{#if data.actions.progress.provider || data.actions.progress.playlist || data.actions.progress.track}<small>{[data.actions.progress.provider, data.actions.progress.playlist, data.actions.progress.track].filter(Boolean).join(" · ")}</small>{/if}
{#if data.actions.attemptCount && data.actions.maxAttempts}<small>Attempt {data.actions.attemptCount} of {data.actions.maxAttempts}{data.actions.failureCount ? ` · ${data.actions.failureCount} failed` : ""}</small>{/if}
</div>
{#if data.actions.progress.total}<progress max={data.actions.progress.total} value={data.actions.progress.completed ?? 0}>{data.actions.progress.completed ?? 0} / {data.actions.progress.total}</progress>{/if}
{#if data.actions.canCancel && data.actions.latestJobId}<button class="button-secondary" type="button" disabled={Boolean(action)} onclick={() => void perform("cancel", () => home.cancelJob(data!.actions.latestJobId!))}>{action === "cancel" ? "Cancelling…" : "Cancel refresh"}</button>{/if}
</section>
{/if}
<div class="intelligence-grid">
<section class="panel recommendations">
@@ -190,7 +207,7 @@
</AlertDialog.Root>
<style>
.intelligence-view{display:grid;gap:1.25rem}.route-heading{display:flex;align-items:end;justify-content:space-between;gap:1rem}.route-heading h2{margin:.25rem 0;font-family:var(--font-display);font-size:clamp(1.5rem,3vw,2.2rem)}.route-heading p:last-child,.muted{color:var(--color-ink-muted)}.heading-actions{display:flex;align-items:center;gap:.75rem}.scope-card{display:grid;grid-template-columns:repeat(3,minmax(0,1fr)) auto;align-items:end;gap:1rem;padding:1rem}.field select{min-height:3rem;border:1px solid var(--color-edge);border-radius:.8rem;background:var(--color-panel-raised);color:var(--color-ink);padding:0 .9rem}.intelligence-grid{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(18rem,.8fr);gap:1rem}.recommendations,.profile-card,.generated-card,.privacy-card{padding:1.15rem}.recommendations>header,.privacy-card>header{display:flex;align-items:center;justify-content:space-between}.recommendations h3,.profile-card h3,.generated-card h3,.privacy-card h3{margin:.2rem 0 1rem}.recommendation-list{display:grid;margin:0;padding:0;list-style:none}.recommendation-list>li{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.85rem;align-items:center;border-top:1px solid var(--color-edge);padding:.9rem 0}.track-art{display:grid;width:3rem;height:3rem;place-items:center;overflow:hidden;border-radius:.7rem;background:var(--color-panel-raised);color:var(--color-signal)}.track-art img{width:100%;height:100%;object-fit:cover}.track-copy{min-width:0}.track-copy>strong,.track-copy>small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.track-copy small,summary{color:var(--color-ink-muted);font-size:.75rem}.track-copy details{margin-top:.35rem}.track-copy ul{margin:.4rem 0 0;padding-left:1.1rem;color:var(--color-ink-muted);font-size:.78rem}.track-actions{display:flex;align-items:center;gap:.35rem}.track-actions button{border:1px solid var(--color-edge);border-radius:.55rem;background:transparent;color:var(--color-ink-muted);padding:.35rem .5rem;cursor:pointer}.score{color:var(--color-signal);font-size:.75rem;font-weight:800}.side-stack{display:grid;align-content:start;gap:1rem}.profile-card label,.generated-row{display:flex;align-items:center;justify-content:space-between;gap:1rem;border-top:1px solid var(--color-edge);padding:.7rem 0}.profile-card meter{width:55%;accent-color:var(--color-signal)}.generated-row span:first-child strong,.generated-row span:first-child small{display:block}.generated-row small{color:var(--color-ink-muted)}.generate-form{display:grid;gap:.75rem;margin-top:1rem}.privacy-card{grid-column:1/-1}.privacy-card form{display:grid;grid-template-columns:minmax(14rem,.6fr) 1fr 1fr;gap:1rem}.toggle-line{display:flex;gap:.75rem}.toggle-line span>*{display:block}.toggle-line small,fieldset small{color:var(--color-ink-muted)}fieldset{display:grid;align-content:start;gap:.55rem;border:0;margin:0;padding:0}fieldset legend{margin-bottom:.55rem;font-weight:750}fieldset label{display:flex;gap:.5rem}.unavailable{opacity:.5}.privacy-card footer{grid-column:1/-1;display:flex;justify-content:space-between;gap:.75rem;border-top:1px solid var(--color-edge);padding-top:1rem}
@media(max-width:900px){.scope-card{grid-template-columns:1fr 1fr}.intelligence-grid{grid-template-columns:1fr}.privacy-card{grid-column:auto}.privacy-card form{grid-template-columns:1fr 1fr}}
@media(max-width:620px){.route-heading{align-items:stretch;flex-direction:column}.scope-card,.privacy-card form{grid-template-columns:1fr}.recommendation-list>li{grid-template-columns:auto minmax(0,1fr)}.track-actions{grid-column:2;flex-wrap:wrap}.privacy-card footer{grid-column:auto;flex-direction:column-reverse}.privacy-card footer>*{width:100%}}
.intelligence-view{display:grid;gap:1.25rem}.route-heading{display:flex;align-items:end;justify-content:space-between;gap:1rem}.route-heading h2{margin:.25rem 0;font-family:var(--font-display);font-size:clamp(1.5rem,3vw,2.2rem)}.route-heading p:last-child,.muted{color:var(--color-ink-muted)}.heading-actions{display:flex;align-items:center;gap:.75rem}.scope-card{display:grid;grid-template-columns:repeat(3,minmax(0,1fr)) auto;align-items:end;gap:1rem;padding:1rem}.field select{min-height:3rem;border:1px solid var(--color-edge);border-radius:.8rem;background:var(--color-panel-raised);color:var(--color-ink);padding:0 .9rem}.run-progress{display:grid;grid-template-columns:minmax(0,1fr) minmax(10rem,.5fr) auto;align-items:center;gap:1rem;padding:1rem}.run-progress p{margin:0}.run-progress small{display:block;color:var(--color-ink-muted)}.run-progress progress{width:100%;accent-color:var(--color-signal)}.intelligence-grid{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(18rem,.8fr);gap:1rem}.recommendations,.profile-card,.generated-card,.privacy-card{padding:1.15rem}.recommendations>header,.privacy-card>header{display:flex;align-items:center;justify-content:space-between}.recommendations h3,.profile-card h3,.generated-card h3,.privacy-card h3{margin:.2rem 0 1rem}.recommendation-list{display:grid;margin:0;padding:0;list-style:none}.recommendation-list>li{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.85rem;align-items:center;border-top:1px solid var(--color-edge);padding:.9rem 0}.track-art{display:grid;width:3rem;height:3rem;place-items:center;overflow:hidden;border-radius:.7rem;background:var(--color-panel-raised);color:var(--color-signal)}.track-art img{width:100%;height:100%;object-fit:cover}.track-copy{min-width:0}.track-copy>strong,.track-copy>small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.track-copy small,summary{color:var(--color-ink-muted);font-size:.75rem}.track-copy details{margin-top:.35rem}.track-copy ul{margin:.4rem 0 0;padding-left:1.1rem;color:var(--color-ink-muted);font-size:.78rem}.track-actions{display:flex;align-items:center;gap:.35rem}.track-actions button{border:1px solid var(--color-edge);border-radius:.55rem;background:transparent;color:var(--color-ink-muted);padding:.35rem .5rem;cursor:pointer}.score{color:var(--color-signal);font-size:.75rem;font-weight:800}.side-stack{display:grid;align-content:start;gap:1rem}.profile-card label,.generated-row{display:flex;align-items:center;justify-content:space-between;gap:1rem;border-top:1px solid var(--color-edge);padding:.7rem 0}.profile-card meter{width:55%;accent-color:var(--color-signal)}.generated-row span:first-child strong,.generated-row span:first-child small{display:block}.generated-row small{color:var(--color-ink-muted)}.generate-form{display:grid;gap:.75rem;margin-top:1rem}.privacy-card{grid-column:1/-1}.privacy-card form{display:grid;grid-template-columns:minmax(14rem,.6fr) 1fr 1fr;gap:1rem}.toggle-line{display:flex;gap:.75rem}.toggle-line span>*{display:block}.toggle-line small,fieldset small{color:var(--color-ink-muted)}fieldset{display:grid;align-content:start;gap:.55rem;border:0;margin:0;padding:0}fieldset legend{margin-bottom:.55rem;font-weight:750}fieldset label{display:flex;gap:.5rem}.unavailable{opacity:.5}.privacy-card footer{grid-column:1/-1;display:flex;justify-content:space-between;gap:.75rem;border-top:1px solid var(--color-edge);padding-top:1rem}
@media(max-width:900px){.scope-card{grid-template-columns:1fr 1fr}.run-progress{grid-template-columns:1fr}.intelligence-grid{grid-template-columns:1fr}.privacy-card{grid-column:auto}.privacy-card form{grid-template-columns:1fr 1fr}}
@media(max-width:620px){.route-heading{align-items:stretch;flex-direction:column}.scope-card,.privacy-card form,.run-progress{grid-template-columns:1fr}.recommendation-list>li{grid-template-columns:auto minmax(0,1fr)}.track-actions{grid-column:2;flex-wrap:wrap}.privacy-card footer{grid-column:auto;flex-direction:column-reverse}.privacy-card footer>*{width:100%}}
</style>
+9 -2
View File
@@ -173,7 +173,12 @@ const responses: Record<string, unknown> = {
enabled: true, available: true, state: "ready",
}],
actions: {
canRun: true, canGenerate: true, latestRunId: "run-1", latestRunState: "succeeded",
canRun: true, canGenerate: true, latestRunId: "run-1", latestRunState: "running",
latestJobId: "job-1", attemptCount: 1, failureCount: 0, maxAttempts: 5,
canCancel: true, progress: {
stage: "recommendation.provider", message: "Searching Lumen Audio.",
completed: 1, total: 2, provider: "lumen-audio", track: "Future Song",
},
},
candidates: [{
id: "candidate-1", trackKey: "track-1", title: "Future Song", artist: "Artist",
@@ -381,9 +386,11 @@ for (const viewport of viewports) {
await page.getByLabel("Library scope").fill("music");
await page.getByRole("button", { name: "Open library" }).click();
await expect(page.getByLabel("Loading Intelligence")).toBeVisible();
await expect(page.getByText("Future Song")).toBeVisible();
await expect(page.getByText("Future Song", { exact: true })).toBeVisible();
await expect(page.getByText("Morning discovery")).toBeVisible();
await expect(page.getByText("Private similarity source. · ready")).toBeVisible();
await expect(page.getByText("Searching Lumen Audio.")).toBeVisible();
await expect(page.getByRole("button", { name: "Cancel refresh" })).toBeInViewport();
await expect(page.getByRole("button", { name: "Refresh recommendations" })).toBeInViewport();
await expect.poll(() => page.evaluate(() =>
document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);