@@ -30,6 +30,7 @@ public class ChatMessage : INotifyPropertyChanged
3030 private static readonly IBrush s_systemBrush = new SolidColorBrush ( Color . FromArgb ( 40 , 200 , 160 , 0 ) ) ;
3131
3232 private string _text = "" ;
33+ private string _timingText = "" ;
3334
3435 public string Role { get ; init ; } = "" ;
3536
@@ -44,6 +45,30 @@ public string Text
4445 }
4546 }
4647
48+ /// <summary>Formatted timing info displayed below assistant bubbles.</summary>
49+ public string TimingText
50+ {
51+ get => _timingText ;
52+ set
53+ {
54+ if ( _timingText == value ) return ;
55+ _timingText = value ;
56+ PropertyChanged ? . Invoke ( this , new PropertyChangedEventArgs ( nameof ( TimingText ) ) ) ;
57+ PropertyChanged ? . Invoke ( this , new PropertyChangedEventArgs ( nameof ( HasTiming ) ) ) ;
58+ }
59+ }
60+
61+ /// <summary>When the user submitted the request that produced this response.</summary>
62+ public DateTimeOffset ? RequestTimestamp { get ; set ; }
63+
64+ /// <summary>Elapsed time from request submission to first response chunk.</summary>
65+ public TimeSpan ? FirstResponseDuration { get ; set ; }
66+
67+ /// <summary>Elapsed time from request submission to final response.</summary>
68+ public TimeSpan ? FinalResponseDuration { get ; set ; }
69+
70+ public bool HasTiming => ! string . IsNullOrEmpty ( _timingText ) ;
71+
4772 public event PropertyChangedEventHandler ? PropertyChanged ;
4873
4974 public HorizontalAlignment HAlign =>
@@ -56,6 +81,27 @@ public string Text
5681
5782 private bool IsUser => string . Equals ( Role , "user" , StringComparison . OrdinalIgnoreCase ) ;
5883 private bool IsSystem => string . Equals ( Role , "system" , StringComparison . OrdinalIgnoreCase ) ;
84+
85+ /// <summary>Updates timing text. Pass <paramref name="elapsed"/> for live updates during streaming.</summary>
86+ public void UpdateTiming ( TimeSpan ? elapsed = null )
87+ {
88+ if ( RequestTimestamp is null ) return ;
89+ var ts = RequestTimestamp . Value . ToLocalTime ( ) . ToString ( "h:mm:ss tt" ) ;
90+ var first = FirstResponseDuration . HasValue
91+ ? FormatDuration ( FirstResponseDuration . Value ) : "—" ;
92+ var current = elapsed ?? FinalResponseDuration ;
93+ var total = current . HasValue ? FormatDuration ( current . Value ) : "—" ;
94+ var suffix = FinalResponseDuration . HasValue ? "" : " ⏳" ;
95+ TimingText = $ "{ ts } · first: { first } · total: { total } { suffix } ";
96+ }
97+
98+ /// <summary>Sets timing text from captured durations (finalized).</summary>
99+ public void SetTimingFromDurations ( ) => UpdateTiming ( ) ;
100+
101+ private static string FormatDuration ( TimeSpan d ) =>
102+ d . TotalSeconds < 1 ? $ "{ d . TotalMilliseconds : F0} ms"
103+ : d . TotalMinutes < 1 ? $ "{ d . TotalSeconds : F1} s"
104+ : $ "{ d . TotalMinutes : F1} m";
59105}
60106
61107public partial class SimplifiedVoiceView : UserControl
@@ -280,14 +326,15 @@ private async Task RunConversationLoopAsync(CancellationToken ct)
280326 var transcript = listenResult . Transcript ! ;
281327
282328 // 2. Show user message in chat
329+ var requestTime = DateTimeOffset . UtcNow ;
283330 _messages . Add ( new ChatMessage { Role = "user" , Text = transcript } ) ;
284331 ScrollToBottom ( ) ;
285332
286333 // 3. Submit turn via streaming
287334 vm . TranscriptInput = transcript ;
288335 SetMicState ( "thinking" ) ;
289336
290- var assistantBubble = new ChatMessage { Role = "assistant" } ;
337+ var assistantBubble = new ChatMessage { Role = "assistant" , RequestTimestamp = requestTime } ;
291338 _messages . Add ( assistantBubble ) ;
292339 ScrollToBottom ( ) ;
293340
@@ -296,6 +343,7 @@ private async Task RunConversationLoopAsync(CancellationToken ct)
296343 var sentenceBuffer = new StringBuilder ( ) ;
297344 var spokenUpTo = 0 ;
298345 var isDone = false ;
346+ var firstChunkReceived = false ;
299347 _isSpeaking = true ;
300348 _ttsStopped = false ;
301349 UpdateButtons ( ) ;
@@ -306,6 +354,14 @@ private async Task RunConversationLoopAsync(CancellationToken ct)
306354
307355 if ( evt . Type == "chunk" && evt . Text is not null )
308356 {
357+ var elapsed = DateTimeOffset . UtcNow - requestTime ;
358+ if ( ! firstChunkReceived )
359+ {
360+ firstChunkReceived = true ;
361+ assistantBubble . FirstResponseDuration = elapsed ;
362+ }
363+ assistantBubble . UpdateTiming ( elapsed ) ;
364+
309365 accumulated . Append ( AnsiEscapePattern . Replace ( evt . Text , "" ) ) ;
310366 assistantBubble . Text = accumulated . ToString ( ) ;
311367 ScrollToBottom ( ) ;
@@ -360,6 +416,7 @@ private async Task RunConversationLoopAsync(CancellationToken ct)
360416 else if ( evt . Type == "done" )
361417 {
362418 isDone = true ;
419+ assistantBubble . FinalResponseDuration = DateTimeOffset . UtcNow - requestTime ;
363420 }
364421 else if ( evt . Type == "error" )
365422 {
@@ -374,6 +431,7 @@ private async Task RunConversationLoopAsync(CancellationToken ct)
374431 if ( isDone )
375432 {
376433 assistantBubble . Text = TextTransformations . ConvertBareUrisToMarkdownLinks ( assistantBubble . Text ?? "" ) ;
434+ assistantBubble . SetTimingFromDurations ( ) ;
377435 ScrollToBottom ( ) ;
378436 }
379437
@@ -425,24 +483,39 @@ private async Task SeedSessionAsync(VoiceConversationViewModel vm, CancellationT
425483 _messages . Add ( new ChatMessage { Role = "system" , Text = seedPrompt } ) ;
426484 ScrollToBottom ( ) ;
427485
428- var seedBubble = new ChatMessage { Role = "assistant" } ;
486+ var seedRequestTime = DateTimeOffset . UtcNow ;
487+ var seedBubble = new ChatMessage { Role = "assistant" , RequestTimestamp = seedRequestTime } ;
429488 _messages . Add ( seedBubble ) ;
430489 ScrollToBottom ( ) ;
431490
432491 var seedAccum = new StringBuilder ( ) ;
492+ var seedFirstChunk = false ;
433493 await foreach ( var evt in vm . SubmitTurnStreamingAsync ( seedPrompt , ct ) . ConfigureAwait ( true ) )
434494 {
435495 if ( evt . Type == "chunk" && evt . Text is not null )
436496 {
497+ var seedElapsed = DateTimeOffset . UtcNow - seedRequestTime ;
498+ if ( ! seedFirstChunk )
499+ {
500+ seedFirstChunk = true ;
501+ seedBubble . FirstResponseDuration = seedElapsed ;
502+ }
503+ seedBubble . UpdateTiming ( seedElapsed ) ;
437504 seedAccum . Append ( evt . Text ) ;
438505 seedBubble . Text = seedAccum . ToString ( ) ;
439506 ScrollToBottom ( ) ;
440507 }
508+ else if ( evt . Type == "done" )
509+ {
510+ seedBubble . FinalResponseDuration = DateTimeOffset . UtcNow - seedRequestTime ;
511+ }
441512 }
442513
443514 if ( seedAccum . Length == 0 )
444515 seedBubble . Text = vm . AssistantDisplayText ?? vm . StatusText ?? "(no response)" ;
445516
517+ seedBubble . SetTimingFromDurations ( ) ;
518+
446519 await _tts . SpeakAsync ( "Copilot ready" , vm . Language , ct ) . ConfigureAwait ( true ) ;
447520 SetStatus ( "Copilot ready. Listening..." ) ;
448521 }
0 commit comments