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