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.
This commit is contained in:
@@ -120,6 +120,13 @@ class ChatViewModel {
|
|||||||
var messages: [Message] = []
|
var messages: [Message] = []
|
||||||
var inputText: String = ""
|
var inputText: String = ""
|
||||||
var isGenerating: Bool = false
|
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 sessionStats = SessionStats()
|
||||||
var selectedModel: ModelInfo?
|
var selectedModel: ModelInfo?
|
||||||
var currentProvider: Settings.Provider = .openrouter
|
var currentProvider: Settings.Provider = .openrouter
|
||||||
@@ -1589,6 +1596,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
streamingTask = Task {
|
streamingTask = Task {
|
||||||
let startTime = Date()
|
let startTime = Date()
|
||||||
var wasCancelled = false
|
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 {
|
do {
|
||||||
// Include web_search tool when online mode is on (not needed for OpenRouter — it handles search via :online suffix)
|
// 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)
|
let tools = mcp.getToolSchemas(onlineMode: onlineMode && currentProvider != .openrouter)
|
||||||
@@ -1741,15 +1752,16 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
break
|
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 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
|
var toolDetails: [ToolCallDetail] = toolCalls.map { tc in
|
||||||
ToolCallDetail(name: tc.functionName, input: tc.arguments, result: nil)
|
ToolCallDetail(name: tc.functionName, input: tc.arguments, result: nil)
|
||||||
}
|
}
|
||||||
updateToolCallMessage(id: toolMsgId, details: toolDetails)
|
|
||||||
|
|
||||||
let usingTextCalls = !textCalls.isEmpty
|
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\"}"
|
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
|
toolDetails[i].result = resultJSON
|
||||||
updateToolCallMessage(id: toolMsgId, details: toolDetails)
|
|
||||||
|
|
||||||
if usingTextCalls {
|
if usingTextCalls {
|
||||||
// Inject results as a user message for text-call models
|
// 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])
|
apiMessages.append(["role": "user", "content": combined])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allToolCallDetails.append(contentsOf: toolDetails)
|
||||||
|
|
||||||
// If this was the last iteration, note it
|
// If this was the last iteration, note it
|
||||||
if iteration == maxIterations - 1 {
|
if iteration == maxIterations - 1 {
|
||||||
hitIterationLimit = true // We're exiting with pending tool calls
|
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
|
wasCancelled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
flushToolCallSummary(allToolCallDetails)
|
||||||
|
|
||||||
// If we hit the iteration limit or the model returned no text at all, silently
|
// 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.
|
// nudge a follow-up turn instead of showing a placeholder/blank bubble.
|
||||||
let willAutoContinue = (hitIterationLimit || finishedWithEmptyContent) && !wasCancelled
|
let willAutoContinue = (hitIterationLimit || finishedWithEmptyContent) && !wasCancelled
|
||||||
@@ -1891,6 +1907,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
} catch {
|
} catch {
|
||||||
let responseTime = Date().timeIntervalSince(startTime)
|
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
|
// Check if this was a cancellation
|
||||||
let isCancellation = Task.isCancelled || wasCancelled || error is CancellationError
|
let isCancellation = Task.isCancelled || wasCancelled || error is CancellationError
|
||||||
|
|
||||||
@@ -1936,6 +1956,16 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
// MARK: - Error Helpers
|
||||||
|
|
||||||
private func friendlyErrorMessage(from error: Error) -> String {
|
private func friendlyErrorMessage(from error: Error) -> String {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ struct ChatView: View {
|
|||||||
|
|
||||||
// Processing indicator
|
// Processing indicator
|
||||||
if viewModel.isGenerating && viewModel.messages.last?.isStreaming != true {
|
if viewModel.isGenerating && viewModel.messages.last?.isStreaming != true {
|
||||||
ProcessingIndicator()
|
ProcessingIndicator(toolActivity: viewModel.currentToolActivity)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,10 +156,14 @@ struct ChatView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ProcessingIndicator: 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 animating = false
|
||||||
@State private var thinkingText = ThinkingVerbs.random()
|
@State private var thinkingText = ThinkingVerbs.random()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Text(thinkingText)
|
Text(thinkingText)
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.system(size: 14, weight: .medium))
|
||||||
@@ -180,6 +184,15 @@ struct ProcessingIndicator: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(.horizontal, 16)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.background(Color.confabSecondary.opacity(0.05))
|
.background(Color.confabSecondary.opacity(0.05))
|
||||||
|
|||||||
Reference in New Issue
Block a user