diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift index 1a3e4db..f78f191 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift @@ -44,11 +44,41 @@ public final class RichChatViewModel { public var messages: [HermesMessage] = [] public var currentSession: HermesSession? public var messageGroups: [MessageGroup] = [] + /// True from the moment the user sends a prompt until the ACP + /// `promptComplete` event arrives. Covers the whole round-trip + /// including auxiliary post-processing (title generation, usage + /// accounting, etc.). UIs should prefer the `isGenerating` / + /// `isPostProcessing` pair below — they distinguish "agent is + /// thinking about your message" from "agent is closing out" and + /// avoid the misleading "spinner after the reply has landed" UX + /// we saw in pass-1 (M7 #4). public var isAgentWorking = false public var pendingPermission: PendingPermission? /// Mutated to trigger a scroll-to-bottom in the message list. public var scrollTrigger = UUID() + /// True while the assistant hasn't yet emitted a complete reply + /// for the latest user prompt. Renders the prominent "Agent is + /// thinking…" indicator in the chat. Flips false as soon as we've + /// finalized an assistant message with content — even if the ACP + /// `promptComplete` event hasn't arrived yet (Hermes auxiliary + /// work like title generation delays that event). + public var isGenerating: Bool { + isAgentWorking && !isPostProcessing + } + + /// True while ACP hasn't closed out the prompt but the assistant + /// has already finalized a reply the user can see. Renders a + /// subtle "Finishing up…" pill instead of the prominent spinner. + /// Avoids the pass-1 M7 #4 UX where users stared at "Agent is + /// working…" forever because `promptComplete` was held up by + /// auxiliary server-side work. + public var isPostProcessing: Bool { + guard isAgentWorking else { return false } + guard let last = messages.last else { return false } + return last.isAssistant && !last.content.isEmpty + } + // Cumulative ACP token tracking (ACP returns tokens per prompt but DB has none) public private(set) var acpInputTokens = 0 public private(set) var acpOutputTokens = 0 diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index 9a81bc0..f42932c 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -94,15 +94,26 @@ struct ChatView: View { MessageBubble(message: msg) .id(msg.id) } - if controller.vm.isAgentWorking { + if controller.vm.isGenerating { HStack { ProgressView() - Text("Agent is working…") + Text("Agent is thinking…") .font(.caption) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal) + } else if controller.vm.isPostProcessing { + HStack(spacing: 6) { + Image(systemName: "ellipsis") + .font(.caption2) + .foregroundStyle(.tertiary) + Text("Finishing up…") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) } Color.clear .frame(height: 1) diff --git a/scarf/scarf/Features/Chat/Views/RichChatView.swift b/scarf/scarf/Features/Chat/Views/RichChatView.swift index 1347c3e..863fd85 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatView.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatView.swift @@ -15,7 +15,13 @@ struct RichChatView: View { VStack(spacing: 0) { SessionInfoBar( session: richChat.currentSession, - isWorking: richChat.isAgentWorking, + // Prefer `isGenerating` over the raw `isAgentWorking` + // so the info bar drops the spinner as soon as the + // assistant's reply is visible, even while ACP + // auxiliary work (title gen, usage accounting) is + // still in flight. See RichChatViewModel docs — same + // fix as ScarfGo for pass-1 M7 #4. + isWorking: richChat.isGenerating, acpInputTokens: richChat.acpInputTokens, acpOutputTokens: richChat.acpOutputTokens, acpThoughtTokens: richChat.acpThoughtTokens, @@ -34,7 +40,7 @@ struct RichChatView: View { // which manifests as a white flash. RichChatMessageList( groups: richChat.messageGroups, - isWorking: richChat.isAgentWorking, + isWorking: richChat.isGenerating, isLoadingSession: chatViewModel.isPreparingSession, scrollTrigger: richChat.scrollTrigger )