using AIStudio.Components;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Assistants.VisualBriefing;
///
/// Renders the staged progress, durations, and failures of one visual briefing build.
///
///
/// The component derives everything it shows from alone. It also owns the timer
/// that keeps the duration of a running stage current, so a build in progress re-renders this panel
/// once per second instead of the entire assistant page.
///
public partial class VisualBriefingBuildProgress : MSGComponentBase
{
///
/// Gets or sets the build whose progress is displayed.
///
[Parameter, EditorRequired]
public VisualBriefingBuildRecord? Build { get; set; }
///
/// Gets or sets whether the resume action is blocked because other work is running.
///
[Parameter]
public bool Disabled { get; set; }
///
/// Gets or sets the callback raised when the user resumes a failed or canceled build.
///
[Parameter]
public EventCallback OnResume { get; set; }
///
/// The six UI groups covering the eight durable build stages.
///
private static readonly VisualBriefingBuildStage[][] STAGE_GROUPS =
[
[VisualBriefingBuildStage.SOURCE_PREPARATION],
[VisualBriefingBuildStage.EVIDENCE],
[VisualBriefingBuildStage.PLAN],
[VisualBriefingBuildStage.CONTENT],
[VisualBriefingBuildStage.DESIGN],
[VisualBriefingBuildStage.COMPILATION, VisualBriefingBuildStage.ASSEMBLY, VisualBriefingBuildStage.COMMIT],
];
/// Stops the live build-duration monitor.
private readonly CancellationTokenSource durationMonitorCancellation = new();
/// Stores the shared timestamp used to render consistent live build durations.
private DateTimeOffset durationReferenceUtc = DateTimeOffset.UtcNow;
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
_ = this.MonitorBuildDurationAsync(this.durationMonitorCancellation.Token);
}
protected override void OnParametersSet()
{
// The parent re-renders us whenever it received a progress update, so this is the moment the
// durations of running stages must be measured against again.
this.durationReferenceUtc = DateTimeOffset.UtcNow;
}
#endregion
#region Overrides of MSGComponentBase
protected override void DisposeResources()
{
this.durationMonitorCancellation.Cancel();
this.durationMonitorCancellation.Dispose();
base.DisposeResources();
}
#endregion
///
/// Refreshes live build durations at most once per second while a stage is running.
///
/// The token that stops the monitor.
/// A task that completes once the monitor was stopped.
private async Task MonitorBuildDurationAsync(CancellationToken token)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
try
{
while (await timer.WaitForNextTickAsync(token))
{
// This panel stays on screen for as long as the briefing has any build, so most of the
// time there is no running stage and nothing to refresh. The check happens here rather
// than inside the callback below, because otherwise every second would still cost a hop
// onto the renderer just to find that out. Reading the build here is safe: the progress
// service publishes snapshots, so this record is never the one the build mutates.
if (this.Build?.Stages.Any(stage => stage.Status is VisualBriefingBuildStageStatus.RUNNING) != true)
continue;
await this.InvokeAsync(() =>
{
this.durationReferenceUtc = DateTimeOffset.UtcNow;
this.StateHasChanged();
});
}
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
}
}
///
/// Gets the localized title of one build step.
///
/// The zero-based index of the step.
/// The localized step title.
private string StepTitle(int index) => index switch
{
0 => T("Prepare sources"),
1 => T("Analyze material"),
2 => T("Plan briefing"),
3 => T("Curate content"),
4 => T("Design presentation"),
_ => T("Compile and save"),
};
/// Gets the active build stepper index.
private int BuildStepperIndex
{
get
{
for (var index = 0; index < STAGE_GROUPS.Length; index++)
{
var statuses = STAGE_GROUPS[index].Select(this.StageStatus).ToArray();
if (statuses.Any(status => status is VisualBriefingBuildStageStatus.RUNNING or VisualBriefingBuildStageStatus.FAILED or VisualBriefingBuildStageStatus.CANCELED))
return index;
if (statuses.Any(status => status is VisualBriefingBuildStageStatus.NOT_STARTED))
return index;
}
return STAGE_GROUPS.Length - 1;
}
}
///
/// Gets the localized collapsed build-progress summary.
///
private string BuildProgressTitle
{
get
{
if(this.Build is null)
return $"{T("Build progress")} · {T("Running")}";
var title = this.Build.Status switch
{
VisualBriefingBuildStatus.COMPLETED => $"{T("Build progress")} · {T("Completed")}",
VisualBriefingBuildStatus.FAILED => $"{T("Build progress")} · {T("Failed")}",
VisualBriefingBuildStatus.CANCELED => $"{T("Build progress")} · {T("Canceled")}",
VisualBriefingBuildStatus.AWAITING_REBUILD => $"{T("Build progress")} · {T("Action required")}",
_ => $"{T("Build progress")} · {T("Running")}",
};
var duration = this.CalculateBuildDuration(this.Build.Stages);
return duration > TimeSpan.Zero ? $"{title} · {FormatBuildDuration(duration)}" : title;
}
}
///
/// Gets a persistent stage status, defaulting to not started.
///
/// The stage to look up.
/// The stage status.
private VisualBriefingBuildStageStatus StageStatus(VisualBriefingBuildStage stage) => this.Build?.Stages.FirstOrDefault(item => item.Stage == stage)?.Status ?? VisualBriefingBuildStageStatus.NOT_STARTED;
///
/// Gets whether one UI group completed or was reused.
///
/// The zero-based index of the group.
/// true when the group finished.
private bool BuildGroupCompleted(int index) => STAGE_GROUPS[index].All(stage => this.StageStatus(stage) is VisualBriefingBuildStageStatus.COMPLETED or VisualBriefingBuildStageStatus.SKIPPED);
///
/// Gets whether one UI group failed.
///
/// The zero-based index of the group.
/// true when the group failed.
private bool BuildGroupFailed(int index) => STAGE_GROUPS[index].Any(stage => this.StageStatus(stage) is VisualBriefingBuildStageStatus.FAILED);
///
/// Gets whether one UI group was canceled.
///
/// The zero-based index of the group.
/// true when the group was canceled.
private bool BuildGroupCanceled(int index) => STAGE_GROUPS[index].Any(stage => this.StageStatus(stage) is VisualBriefingBuildStageStatus.CANCELED);
///
/// Gets whether one UI group stopped with a failure or cancellation.
///
/// The zero-based index of the group.
/// true when the group stopped.
private bool BuildGroupStopped(int index) => this.BuildGroupFailed(index) || this.BuildGroupCanceled(index);
///
/// Gets whether one UI group is active.
///
/// The zero-based index of the group.
/// true when the group is running.
private bool BuildGroupRunning(int index) => STAGE_GROUPS[index].Any(stage => this.StageStatus(stage) is VisualBriefingBuildStageStatus.RUNNING);
///
/// Formats a safe localized status summary and duration.
///
/// The zero-based index of the group.
/// The localized summary.
private string BuildGroupSummary(int index)
{
if(this.Build is null)
return T("Not started");
var records = STAGE_GROUPS[index]
.Select(stage => this.Build.Stages.FirstOrDefault(item => item.Stage == stage))
.Where(record => record is not null)
.Cast()
.ToArray();
var status = this.BuildGroupRunning(index)
? T("Running")
: this.BuildGroupFailed(index)
? T("Failed")
: this.BuildGroupCanceled(index)
? T("Canceled")
: records.Length > 0 && records.All(record => record.Status is VisualBriefingBuildStageStatus.SKIPPED)
? T("Reused")
: this.BuildGroupCompleted(index)
? T("Completed")
: T("Not started");
var duration = this.CalculateBuildDuration(records);
return duration > TimeSpan.Zero ? $"{status} · {FormatBuildDuration(duration)}" : status;
}
///
/// Calculates active processing time without counting reused stages or time between resume attempts.
///
/// The stage records to aggregate.
/// The aggregated duration.
private TimeSpan CalculateBuildDuration(IEnumerable records) => records
.Where(record => record.StartedAtUtc is not null && record.Status is not VisualBriefingBuildStageStatus.SKIPPED)
.Aggregate(TimeSpan.Zero, (total, record) => total + this.CalculateStageDuration(record));
///
/// Calculates one stage duration against the shared live timestamp.
///
/// The stage record to measure.
/// The stage duration.
private TimeSpan CalculateStageDuration(VisualBriefingBuildStageRecord record)
{
var finishedAtUtc = record.Status is VisualBriefingBuildStageStatus.RUNNING ? this.durationReferenceUtc : record.FinishedAtUtc;
if (record.StartedAtUtc is null || finishedAtUtc is null)
return TimeSpan.Zero;
var duration = finishedAtUtc.Value - record.StartedAtUtc.Value;
return duration > TimeSpan.Zero ? duration : TimeSpan.Zero;
}
///
/// Formats a build duration in seconds using the current culture.
///
/// The duration to format.
/// The formatted duration.
private static string FormatBuildDuration(TimeSpan duration) => $"{duration.TotalSeconds:0.0} s";
///
/// Gets the safe failure reason for a UI group.
///
///
/// The recorded issue text of a failure is stable English contract language, because it also goes
/// back to the model and into the persisted build record. The text shown here is therefore derived
/// from the stable enums in the current language instead.
///
/// The zero-based index of the group.
/// The user-facing failure message.
private string BuildGroupFailure(int index) => this.Build is null ? string.Empty : STAGE_GROUPS[index]
.Select(stage => this.Build.Stages.FirstOrDefault(item => item.Stage == stage)?.Failure)
.FirstOrDefault(failure => failure is not null)?.ToUserMessage() ?? this.Build.Failure?.ToUserMessage() ?? string.Empty;
}