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
@@ -218,6 +218,40 @@ public final class RichChatViewModel {
/// model just owns the value. /// model just owns the value.
public var transientHint: String? public var transientHint: String?
/// Wall-clock start time of the current agent turn. Set when a fresh
/// user prompt enters an idle session (not for `/steer` which sends
/// during an active turn); cleared on `finalizeStreamingMessage`
/// after the duration is captured. Used to compute the per-turn
/// stopwatch displayed below assistant bubbles. v2.5.
private var currentTurnStart: Date?
/// Wall-clock duration of completed assistant turns, keyed by the
/// finalised assistant message's local id. Render the value in the
/// chat UI as a small "4.2s" pill below the bubble. Map grows
/// alongside the message list; cleared on `reset()`.
public private(set) var turnDurations: [Int: TimeInterval] = [:]
/// Look up a completed turn's duration. Nil for the streaming
/// placeholder (still in flight) and for any assistant message
/// that pre-dates the v2.5 stopwatch (e.g., loaded from state.db
/// for a resumed session).
public func turnDuration(forMessageId id: Int) -> TimeInterval? {
turnDurations[id]
}
/// Format a duration as a compact stopwatch label used by the chat
/// UI: `0.8s`, `4.2s`, `1m 12s`. Sub-second values render with one
/// decimal place; 60s switches to `<m>m <s>s`.
public static func formatTurnDuration(_ seconds: TimeInterval) -> String {
if seconds < 60 {
return String(format: "%.1fs", seconds)
}
let totalSeconds = Int(seconds.rounded())
let minutes = totalSeconds / 60
let remainder = totalSeconds % 60
return "\(minutes)m \(remainder)s"
}
/// Merged slash-menu list. Precedence: **ACP > project-scoped > /// Merged slash-menu list. Precedence: **ACP > project-scoped >
/// quick_commands** (most specific source wins). De-duplicated by name. /// quick_commands** (most specific source wins). De-duplicated by name.
/// Non-interruptive ACP commands (`/steer`) are always appended at /// Non-interruptive ACP commands (`/steer`) are always appended at
@@ -345,6 +379,9 @@ public final class RichChatViewModel {
acpCachedReadTokens = 0 acpCachedReadTokens = 0
acpCommands = [] acpCommands = []
projectScopedCommands = [] projectScopedCommands = []
currentTurnStart = nil
turnDurations = [:]
transientHint = nil
pendingPermission = nil pendingPermission = nil
loadQuickCommands() loadQuickCommands()
} }
@@ -395,6 +432,15 @@ public final class RichChatViewModel {
reasoning: nil reasoning: nil
) )
messages.append(message) messages.append(message)
// Per-turn stopwatch (v2.5): record the start time only when
// we're entering a fresh agent turn. /steer-style mid-run sends
// arrive while isAgentWorking is already true; preserve the
// existing start so the captured duration reflects the FULL
// turn (initial prompt final reply), not just the time since
// the user nudged.
if !isAgentWorking {
currentTurnStart = Date()
}
isAgentWorking = true isAgentWorking = true
streamingAssistantText = "" streamingAssistantText = ""
streamingThinkingText = "" streamingThinkingText = ""
@@ -708,6 +754,15 @@ public final class RichChatViewModel {
finishReason: streamingToolCalls.isEmpty ? "stop" : nil, finishReason: streamingToolCalls.isEmpty ? "stop" : nil,
reasoning: streamingThinkingText.isEmpty ? nil : streamingThinkingText reasoning: streamingThinkingText.isEmpty ? nil : streamingThinkingText
) )
// Capture per-turn duration so the chat UI can render the
// stopwatch pill (v2.5). Skips assistants we don't have a
// start time for e.g., the .promptComplete fired but the
// turn began before this VM was constructed (shouldn't
// happen in practice but guards an edge case).
if let start = currentTurnStart {
turnDurations[id] = Date().timeIntervalSince(start)
currentTurnStart = nil
}
} else { } else {
// Remove empty streaming placeholder // Remove empty streaming placeholder
messages.remove(at: idx) messages.remove(at: idx)
+17 -2
View File
@@ -165,8 +165,11 @@ struct ChatView: View {
} }
} }
ForEach(controller.vm.messages) { msg in ForEach(controller.vm.messages) { msg in
MessageBubble(message: msg) MessageBubble(
.id(msg.id) message: msg,
turnDuration: controller.vm.turnDuration(forMessageId: msg.id)
)
.id(msg.id)
} }
if controller.vm.isGenerating { if controller.vm.isGenerating {
HStack { HStack {
@@ -933,6 +936,11 @@ private struct PermissionWrapper: Identifiable {
private struct MessageBubble: View { private struct MessageBubble: View {
let message: HermesMessage 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 { var body: some View {
if message.isToolResult { 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) } if !message.isUser { Spacer(minLength: 40) }
} }
@@ -10,6 +10,11 @@ struct RichChatMessageList: View {
var isLoadingSession: Bool = false var isLoadingSession: Bool = false
/// External trigger to force a scroll-to-bottom (e.g., from "Return to Active Session"). /// External trigger to force a scroll-to-bottom (e.g., from "Return to Active Session").
var scrollTrigger: UUID = UUID() var scrollTrigger: UUID = UUID()
/// Wall-clock turn durations indexed by assistant-message id.
/// Threaded through to `MessageGroupView` `RichMessageBubble` so the
/// bubble's metadata footer can render the v2.5 stopwatch pill.
/// Defaults empty so callers that don't care can omit it.
var turnDurations: [Int: TimeInterval] = [:]
/// Scrolling strategy: plain `VStack` (not `LazyVStack`) plus /// Scrolling strategy: plain `VStack` (not `LazyVStack`) plus
/// `.defaultScrollAnchor(.bottom)`. /// `.defaultScrollAnchor(.bottom)`.
@@ -53,7 +58,7 @@ struct RichChatMessageList: View {
} }
ForEach(groups) { group in ForEach(groups) { group in
MessageGroupView(group: group) MessageGroupView(group: group, turnDurations: turnDurations)
.id("group-\(group.id)") .id("group-\(group.id)")
} }
@@ -133,6 +138,11 @@ struct RichChatMessageList: View {
struct MessageGroupView: View { struct MessageGroupView: View {
let group: MessageGroup let group: MessageGroup
/// Wall-clock turn durations keyed by assistant-message id (v2.5).
/// Forwarded into `RichMessageBubble` so the metadata footer can
/// render the stopwatch pill. Defaults empty so existing callers
/// that haven't been updated yet still compile.
var turnDurations: [Int: TimeInterval] = [:]
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
@@ -151,7 +161,11 @@ struct MessageGroupView: View {
// group's lifetime. // group's lifetime.
let assistantMessages = group.assistantMessages.filter(\.isAssistant) let assistantMessages = group.assistantMessages.filter(\.isAssistant)
ForEach(Array(assistantMessages.enumerated()), id: \.offset) { _, message in ForEach(Array(assistantMessages.enumerated()), id: \.offset) { _, message in
RichMessageBubble(message: message, toolResults: group.toolResults) RichMessageBubble(
message: message,
toolResults: group.toolResults,
turnDuration: turnDurations[message.id]
)
} }
if group.toolCallCount > 1 { if group.toolCallCount > 1 {
@@ -42,7 +42,8 @@ struct RichChatView: View {
groups: richChat.messageGroups, groups: richChat.messageGroups,
isWorking: richChat.isGenerating, isWorking: richChat.isGenerating,
isLoadingSession: chatViewModel.isPreparingSession, isLoadingSession: chatViewModel.isPreparingSession,
scrollTrigger: richChat.scrollTrigger scrollTrigger: richChat.scrollTrigger,
turnDurations: richChat.turnDurations
) )
Divider() Divider()
@@ -4,6 +4,12 @@ import ScarfCore
struct RichMessageBubble: View { struct RichMessageBubble: View {
let message: HermesMessage let message: HermesMessage
let toolResults: [String: HermesMessage] let toolResults: [String: HermesMessage]
/// Wall-clock duration of the agent turn this assistant message
/// belongs to (v2.5). Rendered as a compact stopwatch pill in the
/// metadata footer when present. Nil for user bubbles, for the
/// streaming-in-progress placeholder, and for resumed sessions
/// loaded from `state.db` (no live timing available).
var turnDuration: TimeInterval? = nil
var body: some View { var body: some View {
if message.isUser { if message.isUser {
@@ -133,6 +139,10 @@ struct RichMessageBubble: View {
if let time = message.timestamp { if let time = message.timestamp {
Text(time, style: .time) Text(time, style: .time)
} }
if let seconds = turnDuration {
Text(RichChatViewModel.formatTurnDuration(seconds))
.help("Wall-clock duration of this turn")
}
} }
.font(.caption2) .font(.caption2)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)