Skip to content

Commit 1c7898c

Browse files
sharpninjaCopilot
andcommitted
feat: real-time timing display below voice chat response bubbles
- Add timestamp, first-response latency, and total duration to ChatMessage - Update timing live on every streaming chunk with hourglass indicator - Finalize timing when response completes - Show as italic sub-text below assistant markdown bubbles - Applied to both conversation loop and seed session flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b4b2153 commit 1c7898c

2 files changed

Lines changed: 82 additions & 3 deletions

File tree

src/McpServerManager.Android/Views/SimplifiedVoiceView.axaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,13 @@
3535
HorizontalAlignment="Stretch"
3636
Background="{Binding BubbleBrush}"
3737
IsVisible="{Binding IsAssistant}">
38-
<md:MarkdownScrollViewer Markdown="{Binding Text}" Margin="0"/>
38+
<StackPanel>
39+
<md:MarkdownScrollViewer Markdown="{Binding Text}" Margin="0"/>
40+
<TextBlock Text="{Binding TimingText}"
41+
IsVisible="{Binding HasTiming}"
42+
FontSize="13" Opacity="0.5" Margin="2,4,2,0"
43+
FontStyle="Italic" TextWrapping="Wrap"/>
44+
</StackPanel>
3945
</Border>
4046
</Panel>
4147
</DataTemplate>

src/McpServerManager.Android/Views/SimplifiedVoiceView.axaml.cs

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

61107
public 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

Comments
 (0)