From 70d4c97a6cf401f85d5be64a2f8028521fa79173 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 25 Apr 2026 09:01:26 +0200 Subject: [PATCH] feat(chat): per-turn stopwatch on assistant bubbles (Phase 2.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ViewModels/RichChatViewModel.swift | 55 +++++++++++++++++++ scarf/Scarf iOS/Chat/ChatView.swift | 19 ++++++- .../Chat/Views/RichChatMessageList.swift | 18 +++++- .../Features/Chat/Views/RichChatView.swift | 3 +- .../Chat/Views/RichMessageBubble.swift | 10 ++++ 5 files changed, 100 insertions(+), 5 deletions(-) diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift index d89181f..84e2abe 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift @@ -218,6 +218,40 @@ public final class RichChatViewModel { /// model just owns the value. 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 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 > /// quick_commands** (most specific source wins). De-duplicated by name. /// Non-interruptive ACP commands (`/steer`) are always appended at @@ -345,6 +379,9 @@ public final class RichChatViewModel { acpCachedReadTokens = 0 acpCommands = [] projectScopedCommands = [] + currentTurnStart = nil + turnDurations = [:] + transientHint = nil pendingPermission = nil loadQuickCommands() } @@ -395,6 +432,15 @@ public final class RichChatViewModel { reasoning: nil ) 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 streamingAssistantText = "" streamingThinkingText = "" @@ -708,6 +754,15 @@ public final class RichChatViewModel { finishReason: streamingToolCalls.isEmpty ? "stop" : nil, 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 { // Remove empty streaming placeholder messages.remove(at: idx) diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index d263101..5cddf27 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -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) } } diff --git a/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift b/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift index 1841b02..fa4729c 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift @@ -10,6 +10,11 @@ struct RichChatMessageList: View { var isLoadingSession: Bool = false /// External trigger to force a scroll-to-bottom (e.g., from "Return to Active Session"). 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 /// `.defaultScrollAnchor(.bottom)`. @@ -53,7 +58,7 @@ struct RichChatMessageList: View { } ForEach(groups) { group in - MessageGroupView(group: group) + MessageGroupView(group: group, turnDurations: turnDurations) .id("group-\(group.id)") } @@ -133,6 +138,11 @@ struct RichChatMessageList: View { struct MessageGroupView: View { 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 { VStack(alignment: .leading, spacing: 8) { @@ -151,7 +161,11 @@ struct MessageGroupView: View { // group's lifetime. let assistantMessages = group.assistantMessages.filter(\.isAssistant) 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 { diff --git a/scarf/scarf/Features/Chat/Views/RichChatView.swift b/scarf/scarf/Features/Chat/Views/RichChatView.swift index 0897ed8..2f431aa 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatView.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatView.swift @@ -42,7 +42,8 @@ struct RichChatView: View { groups: richChat.messageGroups, isWorking: richChat.isGenerating, isLoadingSession: chatViewModel.isPreparingSession, - scrollTrigger: richChat.scrollTrigger + scrollTrigger: richChat.scrollTrigger, + turnDurations: richChat.turnDurations ) Divider() diff --git a/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift b/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift index 8ca7d84..29e48f4 100644 --- a/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift +++ b/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift @@ -4,6 +4,12 @@ import ScarfCore struct RichMessageBubble: View { let message: 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 { if message.isUser { @@ -133,6 +139,10 @@ struct RichMessageBubble: View { if let time = message.timestamp { Text(time, style: .time) } + if let seconds = turnDuration { + Text(RichChatViewModel.formatTurnDuration(seconds)) + .help("Wall-clock duration of this turn") + } } .font(.caption2) .foregroundStyle(.tertiary)