diff --git a/clients/macos/vellum-assistant/Features/Chat/UsedToolsList.swift b/clients/macos/vellum-assistant/Features/Chat/UsedToolsList.swift index 192b62616cf..fa7b1ec8d98 100644 --- a/clients/macos/vellum-assistant/Features/Chat/UsedToolsList.swift +++ b/clients/macos/vellum-assistant/Features/Chat/UsedToolsList.swift @@ -82,14 +82,15 @@ private struct UsedToolsRow: View { @State private var isExpanded = false @State private var isHovered = false @State private var isImageHovered = false + /// Cached formatted input — computed once on first expand to avoid re-running + /// `formatAllToolInput` on every SwiftUI render pass. + @State private var cachedInputFull: String? @Environment(\.displayScale) private var displayScale - /// Lazily resolved full input text. If `inputFull` was deferred during history - /// load, compute it from the raw dictionary on first access (when the user - /// expands the row) instead of during `populateFromHistory`. + /// Lazily resolved full input text, using the cached value when available. private var resolvedInputFull: String { + if let cached = cachedInputFull { return cached } if !toolCall.inputFull.isEmpty { return toolCall.inputFull } - if let dict = toolCall.inputRawDict { return ToolCallData.formatAllToolInput(dict) } return "" } @@ -276,6 +277,17 @@ private struct UsedToolsRow: View { } .padding(.bottom, VSpacing.sm) .transition(.opacity.combined(with: .move(edge: .top))) + .onAppear { + // Compute formatted input once when the user first expands, + // rather than re-running formatAllToolInput on every render. + if cachedInputFull == nil { + if !toolCall.inputFull.isEmpty { + cachedInputFull = toolCall.inputFull + } else if let dict = toolCall.inputRawDict { + cachedInputFull = ToolCallData.formatAllToolInput(dict) + } + } + } } } .animation(VAnimation.fast, value: isExpanded) diff --git a/clients/shared/Features/Chat/ChatMessage.swift b/clients/shared/Features/Chat/ChatMessage.swift index 31a7f394e8b..e02514253f8 100644 --- a/clients/shared/Features/Chat/ChatMessage.swift +++ b/clients/shared/Features/Chat/ChatMessage.swift @@ -1023,14 +1023,6 @@ public struct ToolCallData: Identifiable, Equatable { return result } - /// If `inputFull` is empty and `inputRawDict` is available, compute the - /// formatted input on demand. Call this before displaying `inputFull`. - public mutating func ensureInputFullFormatted() { - guard inputFull.isEmpty, let dict = inputRawDict else { return } - inputFull = Self.formatAllToolInput(dict) - inputRawDict = nil - } - private static func stringifyValue(_ value: AnyCodable) -> String { if let s = value.value as? String { return s } if let b = value.value as? Bool { return b ? "true" : "false" } diff --git a/clients/shared/Features/Chat/ChatViewModel.swift b/clients/shared/Features/Chat/ChatViewModel.swift index 9ac6f2b6f23..d96ecf44ff1 100644 --- a/clients/shared/Features/Chat/ChatViewModel.swift +++ b/clients/shared/Features/Chat/ChatViewModel.swift @@ -1738,8 +1738,24 @@ public final class ChatViewModel: ObservableObject { imageData: tc.imageData ) // Defer expensive formatting — store the raw dict for lazy computation - // when the user expands the tool call chip. - toolCall.inputRawDict = tc.input + // when the user expands the tool call chip. Cap the raw dict size + // to prevent unbounded memory from large tool inputs (mirrors the + // 10k-char cap applied in formatAllToolInput). + if let input = tc.input { + let estimatedSize: Int + if let data = try? JSONSerialization.data(withJSONObject: input.mapValues { $0.value ?? NSNull() }) { + estimatedSize = data.count + } else { + estimatedSize = 0 + } + if estimatedSize > 10_000 { + // Too large — format eagerly (with truncation) rather than + // retaining the full raw dictionary in memory. + toolCall.inputFull = ToolCallData.formatAllToolInput(input) + } else { + toolCall.inputRawDict = input + } + } return toolCall } } diff --git a/clients/shared/Features/Chat/ToolCallChip.swift b/clients/shared/Features/Chat/ToolCallChip.swift index 9ea91b8dd04..62e409c2ffb 100644 --- a/clients/shared/Features/Chat/ToolCallChip.swift +++ b/clients/shared/Features/Chat/ToolCallChip.swift @@ -10,17 +10,18 @@ public struct ToolCallChip: View { self.toolCall = toolCall } @State private var isExpanded = false + /// Cached formatted input — computed once on first expand to avoid re-running + /// `formatAllToolInput` on every SwiftUI render pass. + @State private var cachedInputFull: String? private var hasExpandableContent: Bool { toolCall.result != nil || toolCall.cachedImage != nil } - /// Lazily resolved full input text. If `inputFull` was deferred during history - /// load, compute it from the raw dictionary on first access (when the user - /// expands the chip) instead of during `populateFromHistory`. + /// Lazily resolved full input text, using the cached value when available. private var resolvedInputFull: String { + if let cached = cachedInputFull { return cached } if !toolCall.inputFull.isEmpty { return toolCall.inputFull } - if let dict = toolCall.inputRawDict { return ToolCallData.formatAllToolInput(dict) } return "" } @@ -148,6 +149,17 @@ public struct ToolCallChip: View { } } .padding(.bottom, VSpacing.sm) + .onAppear { + // Compute formatted input once when the user first expands, + // rather than re-running formatAllToolInput on every render. + if cachedInputFull == nil { + if !toolCall.inputFull.isEmpty { + cachedInputFull = toolCall.inputFull + } else if let dict = toolCall.inputRawDict { + cachedInputFull = ToolCallData.formatAllToolInput(dict) + } + } + } } } .background(