mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
@@ -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>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 >
|
||||
/// 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)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user