feat(chat): per-turn stopwatch on assistant bubbles (Phase 2.2)

Wall-clock duration of each agent turn renders as a compact pill in
the message metadata footer (Mac) / below the bubble (iOS). Mirrors
the per-turn stopwatch Hermes v2026.4.23's TUI rewrite ships.

ScarfCore RichChatViewModel:
- currentTurnStart: Date? captured in addUserMessage when entering a
  fresh turn (skipped for /steer-style mid-run sends so the duration
  reflects the FULL turn).
- turnDurations: [Int: TimeInterval] keyed by finalised assistant
  message id; populated in finalizeStreamingMessage and cleared on
  reset().
- formatTurnDuration(_:) static — "0.8s" / "4.2s" / "1m 12s".

Mac:
- RichMessageBubble gains turnDuration: TimeInterval?; renders via
  formatTurnDuration in the existing metadata footer.
- RichChatMessageList + MessageGroupView thread the durations dict
  through; RichChatView wires richChat.turnDurations.

iOS:
- MessageBubble gains turnDuration parameter; renders below the
  bubble for assistant messages only.
- ChatView's ForEach passes controller.vm.turnDuration(forMessageId:).

Verified: Mac + iOS builds clean. Resumed sessions (loaded from
state.db) show no pill — turnDurations only populates for live ACP
turns, which is the correct behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-25 09:01:26 +02:00
parent a9bd51bf05
commit 70d4c97a6c
5 changed files with 100 additions and 5 deletions
+17 -2
View File
@@ -165,8 +165,11 @@ struct ChatView: View {
}
}
ForEach(controller.vm.messages) { msg in
MessageBubble(message: msg)
.id(msg.id)
MessageBubble(
message: msg,
turnDuration: controller.vm.turnDuration(forMessageId: msg.id)
)
.id(msg.id)
}
if controller.vm.isGenerating {
HStack {
@@ -933,6 +936,11 @@ private struct PermissionWrapper: Identifiable {
private struct MessageBubble: View {
let message: HermesMessage
/// Wall-clock duration of the agent turn this assistant message
/// belongs to (v2.5). Renders as a small `4.2s` pill below the
/// bubble when present. Nil for user / streaming / pre-v2.5
/// resumed messages.
var turnDuration: TimeInterval? = nil
var body: some View {
if message.isToolResult {
@@ -963,6 +971,13 @@ private struct MessageBubble: View {
}
}
}
// Per-turn stopwatch assistant only, when the
// turn duration was captured (live ACP turns).
if !message.isUser, let seconds = turnDuration {
Text(RichChatViewModel.formatTurnDuration(seconds))
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
if !message.isUser { Spacer(minLength: 40) }
}