From 8875ae9aa9b4d7a045bf546ab233df7ca4606b15 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Wed, 5 Aug 2026 11:33:10 +0200 Subject: [PATCH] Collapse tool-call chat rows into a single live status line Each round of a multi-tool-call chain used to append a new "Calling: X" message, so a long tool chain stacked up a growing list of rows in the transcript. Replaced with a transient status line under the thinking indicator that updates in place each round; once the response completes, the whole chain collapses into one expandable summary message ("Used N tool calls") instead of N separate ones. --- oAI/ViewModels/ChatViewModel.swift | 44 +++++++++++++++++++++++----- oAI/Views/Main/ChatView.swift | 47 +++++++++++++++++++----------- 2 files changed, 67 insertions(+), 24 deletions(-) diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index 3d5e93f..ea6a126 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -120,6 +120,13 @@ class ChatViewModel { var messages: [Message] = [] var inputText: String = "" var isGenerating: Bool = false + /// Live "what's happening right now" line shown under ProcessingIndicator's thinking verb + /// while a tool-calling loop is running — e.g. "🔧 Calling: read_file". Replaced in place each + /// round rather than appending a new chat message, so a long multi-round tool chain doesn't + /// stack up a growing list of rows. Not persisted; nil whenever no tool round is in flight. The + /// full chain is still recorded, just collapsed into one expandable summary message once the + /// loop finishes — see generateAIResponseWithTools's use of allToolCallDetails. + var currentToolActivity: String? = nil var sessionStats = SessionStats() var selectedModel: ModelInfo? var currentProvider: Settings.Provider = .openrouter @@ -1589,6 +1596,10 @@ Don't narrate future actions ("Let me...") - just use the tools. streamingTask = Task { let startTime = Date() var wasCancelled = false + // Accumulates ToolCallDetail entries across every round of this tool-calling loop — + // collapsed into a single expandable summary message once the loop exits (success, + // cancellation, or error), instead of one persisted message per round. + var allToolCallDetails: [ToolCallDetail] = [] do { // Include web_search tool when online mode is on (not needed for OpenRouter — it handles search via :online suffix) let tools = mcp.getToolSchemas(onlineMode: onlineMode && currentProvider != .openrouter) @@ -1741,15 +1752,16 @@ Don't narrate future actions ("Let me...") - just use the tools. break } - // Show what tools the model is calling + // Show what tools the model is calling as a transient status line rather than + // a new chat message — see currentToolActivity's doc comment. let toolNames = toolCalls.map { $0.functionName }.joined(separator: ", ") - let toolMsgId = showSystemMessage("🔧 Calling: \(toolNames)") + currentToolActivity = String(localized: "🔧 Calling: \(toolNames)") - // Initialise detail entries with inputs (results fill in below) + // Initialise detail entries with inputs (results fill in below); appended to + // allToolCallDetails once this round finishes executing. var toolDetails: [ToolCallDetail] = toolCalls.map { tc in ToolCallDetail(name: tc.functionName, input: tc.arguments, result: nil) } - updateToolCallMessage(id: toolMsgId, details: toolDetails) let usingTextCalls = !textCalls.isEmpty @@ -1800,9 +1812,9 @@ Don't narrate future actions ("Let me...") - just use the tools. resultJSON = "{\"error\": \"Failed to serialize result\"}" } - // Update the detail entry with the result so the UI can show it + // Record the result so the collapsed summary message can show it once + // the whole tool-calling loop finishes. toolDetails[i].result = resultJSON - updateToolCallMessage(id: toolMsgId, details: toolDetails) if usingTextCalls { // Inject results as a user message for text-call models @@ -1822,6 +1834,8 @@ Don't narrate future actions ("Let me...") - just use the tools. apiMessages.append(["role": "user", "content": combined]) } + allToolCallDetails.append(contentsOf: toolDetails) + // If this was the last iteration, note it if iteration == maxIterations - 1 { hitIterationLimit = true // We're exiting with pending tool calls @@ -1834,6 +1848,8 @@ Don't narrate future actions ("Let me...") - just use the tools. wasCancelled = true } + flushToolCallSummary(allToolCallDetails) + // If we hit the iteration limit or the model returned no text at all, silently // nudge a follow-up turn instead of showing a placeholder/blank bubble. let willAutoContinue = (hitIterationLimit || finishedWithEmptyContent) && !wasCancelled @@ -1891,6 +1907,10 @@ Don't narrate future actions ("Let me...") - just use the tools. } catch { let responseTime = Date().timeIntervalSince(startTime) + // Same collapse as the success path — any tool rounds that completed before the + // error/cancellation are still worth keeping a record of. + flushToolCallSummary(allToolCallDetails) + // Check if this was a cancellation let isCancellation = Task.isCancelled || wasCancelled || error is CancellationError @@ -1935,7 +1955,17 @@ Don't narrate future actions ("Let me...") - just use the tools. messages[idx].toolCalls = details } } - + + /// Clears the live tool-activity status line and, if any tool calls actually ran, collapses + /// them into a single persisted, expandable summary message — used on every exit path of + /// generateAIResponseWithTools's loop (success, cancellation, or error). + private func flushToolCallSummary(_ details: [ToolCallDetail]) { + currentToolActivity = nil + guard !details.isEmpty else { return } + let summaryId = showSystemMessage("🔧 Used ^[\(details.count) tool call](inflect: true)") + updateToolCallMessage(id: summaryId, details: details) + } + // MARK: - Error Helpers private func friendlyErrorMessage(from error: Error) -> String { diff --git a/oAI/Views/Main/ChatView.swift b/oAI/Views/Main/ChatView.swift index c993c92..f48342b 100644 --- a/oAI/Views/Main/ChatView.swift +++ b/oAI/Views/Main/ChatView.swift @@ -53,7 +53,7 @@ struct ChatView: View { // Processing indicator if viewModel.isGenerating && viewModel.messages.last?.isStreaming != true { - ProcessingIndicator() + ProcessingIndicator(toolActivity: viewModel.currentToolActivity) .padding(.horizontal) } @@ -156,30 +156,43 @@ struct ChatView: View { } struct ProcessingIndicator: View { + /// Current tool round's status (e.g. "🔧 Calling: read_file"), replaced in place each round + /// rather than the chat accumulating a new row per round — see ChatViewModel.currentToolActivity. + let toolActivity: String? @State private var animating = false @State private var thinkingText = ThinkingVerbs.random() var body: some View { - HStack(spacing: 8) { - Text(thinkingText) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.confabSecondary) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(thinkingText) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.confabSecondary) - HStack(spacing: 4) { - ForEach(0..<3) { index in - Circle() - .fill(Color.confabSecondary) - .frame(width: 6, height: 6) - .scaleEffect(animating ? 1.0 : 0.5) - .animation( - .easeInOut(duration: 0.6) - .repeatForever() - .delay(Double(index) * 0.2), - value: animating - ) + HStack(spacing: 4) { + ForEach(0..<3) { index in + Circle() + .fill(Color.confabSecondary) + .frame(width: 6, height: 6) + .scaleEffect(animating ? 1.0 : 0.5) + .animation( + .easeInOut(duration: 0.6) + .repeatForever() + .delay(Double(index) * 0.2), + value: animating + ) + } } } + + if let toolActivity { + Text(toolActivity) + .font(.system(size: 12)) + .foregroundColor(.confabSecondary.opacity(0.75)) + .transition(.opacity) + } } + .animation(.easeInOut(duration: 0.15), value: toolActivity) .padding(.horizontal, 16) .padding(.vertical, 12) .background(Color.confabSecondary.opacity(0.05))