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.
|
/// 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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user