diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Build.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Build.cs index 02478f83..43e673e0 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Build.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Build.cs @@ -57,15 +57,24 @@ public partial class VisualBriefingAssistant /// /// Gets the localized collapsed build-progress summary. /// - private string BuildProgressTitle => this.latestBuild?.Status switch + private string BuildProgressTitle { - 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")}", + get + { + var title = this.latestBuild?.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")}", - }; + _ => $"{T("Build progress")} · {T("Running")}", + }; + + var duration = this.CalculateBuildDuration(this.latestBuild?.Stages ?? []); + return duration > TimeSpan.Zero ? $"{title} · {FormatBuildDuration(duration)}" : title; + } + } /// /// Keeps the status stepper informational while allowing actions inside the active step. @@ -311,8 +320,38 @@ public partial class VisualBriefingAssistant if (this.selectedBriefing?.BriefingId != briefingId) return; - this.latestBuild = this.BuildProgressService.GetLatest(briefingId); - _ = this.InvokeAsync(this.StateHasChanged); + _ = this.InvokeAsync(() => + { + if (this.selectedBriefing?.BriefingId != briefingId) + return; + + this.latestBuild = this.BuildProgressService.GetLatest(briefingId); + this.buildDurationReferenceUtc = DateTimeOffset.UtcNow; + this.StateHasChanged(); + }); + } + + /// + /// Refreshes live build durations at most once per second while a selected stage is running. + /// + private async Task MonitorBuildDurationAsync(CancellationToken token) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + try + { + while (await timer.WaitForNextTickAsync(token)) + await this.InvokeAsync(() => + { + if (this.latestBuild?.Stages.Any(stage => stage.Status is VisualBriefingBuildStageStatus.RUNNING) != true) + return; + + this.buildDurationReferenceUtc = DateTimeOffset.UtcNow; + this.StateHasChanged(); + }); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + } } /// @@ -408,16 +447,35 @@ public partial class VisualBriefingAssistant ? T("Completed") : T("Not started"); - var duration = records - .Where(record => record.StartedAtUtc is not null) - .Aggregate(TimeSpan.Zero, (total, record) => - total + ((record.FinishedAtUtc ?? DateTimeOffset.UtcNow) - record.StartedAtUtc!.Value)); - - return duration > TimeSpan.Zero - ? $"{status} · {duration.TotalSeconds:0.0} s" - : status; + 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. + /// + 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. + /// + private TimeSpan CalculateStageDuration(VisualBriefingBuildStageRecord record) + { + var finishedAtUtc = record.Status is VisualBriefingBuildStageStatus.RUNNING ? this.buildDurationReferenceUtc : 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 using the current UI culture. + /// + private static string FormatBuildDuration(TimeSpan duration) => $"{duration.TotalSeconds:0.0} s"; + /// /// Gets the safe failure reason for a UI group. /// diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.cs index 32e298b1..73075d4e 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.cs @@ -91,6 +91,9 @@ public partial class VisualBriefingAssistant : MSGComponentBase /// Stops the background source-status monitor. private readonly CancellationTokenSource sourceMonitorCancellation = new(); + /// Stops the live build-duration monitor. + private readonly CancellationTokenSource buildDurationMonitorCancellation = new(); + /// Stores available and recoverable projects ordered by most recent modification. private IReadOnlyList projects = []; @@ -169,6 +172,9 @@ public partial class VisualBriefingAssistant : MSGComponentBase /// Stores the latest persistent or live build shown in the stepper. private VisualBriefingBuildRecord? latestBuild; + /// Stores the shared timestamp used to render consistent live build durations. + private DateTimeOffset buildDurationReferenceUtc = DateTimeOffset.UtcNow; + /// Stores incompatible validated content offered for rebuild continuation. private Guid? reusableContentBuildId; @@ -199,6 +205,7 @@ public partial class VisualBriefingAssistant : MSGComponentBase this.BuildProgressService.Changed += this.BuildProgressChanged; await this.ReloadListAsync(); _ = this.MonitorSourceStatusAsync(this.sourceMonitorCancellation.Token); + _ = this.MonitorBuildDurationAsync(this.buildDurationMonitorCancellation.Token); var deferredInstruction = this.MessageBus.CheckDeferredMessages(Event.SEND_TO_VISUAL_BRIEFING_ASSISTANT).FirstOrDefault(); if (!string.IsNullOrWhiteSpace(deferredInstruction)) @@ -220,6 +227,8 @@ public partial class VisualBriefingAssistant : MSGComponentBase { this.sourceMonitorCancellation.Cancel(); this.sourceMonitorCancellation.Dispose(); + this.buildDurationMonitorCancellation.Cancel(); + this.buildDurationMonitorCancellation.Dispose(); this.MediaTranscriptionService.StateChanged -= this.MediaStateChanged; this.BuildProgressService.Changed -= this.BuildProgressChanged; base.DisposeResources();