Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift
Peter Steinberger e0ecdc47be feat(chat-ui): tool-diff follow-ups — cached diffs, apply_patch, isError, long lines (#111326)
* feat(chat-ui): tool-diff follow-ups from PR review

Persist bounded applied-diff details in the transcript cache so cold
opens keep inline diffs; parse apply_patch envelopes into per-file diff
sections with stats; thread tool-result isError so failed calls suppress
proposal diffs and read as failures; replace per-line truncation with
horizontal scrolling for long diff lines.

* chore(i18n): sync native inventory for tool-diff follow-ups

* fix(chat-ui): render move-only patches and drop zero diffstats
2026-07-19 02:40:37 -07:00

414 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Flat rows match the web Control UI's tool rendering and avoid card-in-card chrome.
// Card chrome appears only when a result's detail is expanded.
import Foundation
import OpenClawKit
import SwiftUI
struct ChatToolActivityItem: Identifiable, Equatable {
let id: String
let name: String?
let arguments: AnyCodable?
let details: AnyCodable?
let resultText: String?
let isError: Bool
let isPending: Bool
}
enum ChatToolActivity {
static func items(
calls: [OpenClawChatMessageContent],
results: [OpenClawChatMessageContent]) -> [ChatToolActivityItem]
{
var remainingResults = Array(results.enumerated())
var items = calls.enumerated().map { index, call in
let resultIndex = call.id.flatMap { callID in
remainingResults.firstIndex { _, result in result.id == callID }
}
let result = resultIndex.map { remainingResults.remove(at: $0).element }
return ChatToolActivityItem(
id: call.id ?? "call-\(index)",
name: call.name,
arguments: call.arguments,
details: result?.details,
resultText: result?.text,
isError: result?.isError ?? false,
isPending: false)
}
items.append(contentsOf: remainingResults.map { index, result in
ChatToolActivityItem(
id: result.id ?? "result-\(index)",
name: result.name,
arguments: nil,
details: result.details,
resultText: result.text,
isError: result.isError ?? false,
isPending: false)
})
return items
}
}
struct ChatToolActivityRow: View {
let item: ChatToolActivityItem
private let resolvedDiff: (lines: [ChatToolDiffLine], stat: ChatToolDiffStat?)?
@State private var expanded = false
@State private var showsFullResult = false
private static let disclosureWidth: CGFloat = 12
private static let expandedLineLimit = 40
private var display: ToolDisplaySummary {
ToolDisplayRegistry.resolve(name: self.item.name ?? "tool", args: self.item.arguments)
}
private var detailLine: String? {
guard let detail = self.display.detailLine, !detail.isEmpty else { return nil }
return detail
}
private var formattedResult: String {
guard let resultText = self.item.resultText else { return "" }
return ToolResultTextFormatter.format(text: resultText, toolName: self.item.name)
}
private var expandable: Bool {
self.resolvedDiff != nil || !self.formattedResult.isEmpty
}
private var accessibilityValue: String {
guard self.item.isPending else { return self.detailLine ?? "" }
let running = String(localized: "Running")
return self.detailLine.map { "\(running), \($0)" } ?? running
}
private var expandedLineCount: Int {
self.resolvedDiff?.lines.count ?? self.formattedResult.components(separatedBy: .newlines).count
}
private var isResultTruncated: Bool {
self.expandedLineCount > Self.expandedLineLimit
}
private var expandedResult: String {
guard self.isResultTruncated, !self.showsFullResult else { return self.formattedResult }
let lines = self.formattedResult.components(separatedBy: .newlines)
return lines.prefix(Self.expandedLineLimit - 1).joined(separator: "\n") + "\n"
}
private var expandedDiffLines: [ChatToolDiffLine] {
guard let lines = self.resolvedDiff?.lines else { return [] }
guard self.isResultTruncated, !self.showsFullResult else { return lines }
return Array(lines.prefix(Self.expandedLineLimit - 1)) + [
ChatToolDiffLine(kind: .skip, text: ""),
]
}
init(item: ChatToolActivityItem) {
self.item = item
self.resolvedDiff = ChatToolDiff.resolveDiff(
name: item.name,
arguments: item.arguments,
details: item.details,
isError: item.isError)
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
if self.expandable {
Button {
withAnimation(.easeOut(duration: 0.15)) {
self.expanded.toggle()
}
} label: {
self.collapsedRow
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityElement(children: .ignore)
.accessibilityLabel(self.display.title)
.accessibilityValue(self.accessibilityValue)
.accessibilityHint(self.expanded ? "Collapse tool result" : "Expand tool result")
} else {
self.collapsedRow
.accessibilityElement(children: .ignore)
.accessibilityLabel(self.display.title)
.accessibilityValue(self.accessibilityValue)
}
if self.expanded, self.expandable {
VStack(alignment: .leading, spacing: 6) {
if self.resolvedDiff != nil {
self.diffRows
// The result text carries the outcome (success summary or the
// error diagnostic); hiding it would misrepresent failed edits
// as applied changes. Bounded so foreign harness output cannot
// dwarf the diff.
if !self.formattedResult.isEmpty {
Text(self.formattedResult)
.font(OpenClawChatTypography.mono(size: 12, relativeTo: .footnote))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(Self.expandedLineLimit)
}
} else {
Text(self.expandedResult)
.font(OpenClawChatTypography.mono(size: 12, relativeTo: .footnote))
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
if self.isResultTruncated {
Button {
self.showsFullResult.toggle()
} label: {
Text(
self.showsFullResult
? String(localized: "Show less")
: String(
format: String(localized: "Show all %lld lines"),
Int64(self.expandedLineCount)))
.font(OpenClawChatTypography.caption)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(OpenClawChatTheme.subtleCard))
.padding(.leading, 19)
}
}
}
private var collapsedRow: some View {
HStack(alignment: .firstTextBaseline, spacing: 7) {
Group {
if self.item.isPending {
ProgressView()
.controlSize(.mini)
} else if self.expandable {
Image(systemName: "chevron.right")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.secondary)
.opacity(0.7)
.rotationEffect(.degrees(self.expanded ? 90 : 0))
} else {
Color.clear
}
}
.frame(width: Self.disclosureWidth)
Image(systemName: Self.symbol(forToolName: self.item.name))
.font(.system(size: 12, weight: .medium))
.foregroundStyle(self.item.isError ? OpenClawChatTheme.danger : Color.secondary)
Text(self.display.title)
.font(OpenClawChatTypography.footnoteSemiBold)
.foregroundStyle(
self.item.isError ? OpenClawChatTheme.danger : OpenClawChatTheme.assistantText)
.lineLimit(1)
if let detailLine = self.detailLine {
Text(detailLine)
.font(OpenClawChatTypography.mono(size: 12, relativeTo: .footnote))
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
if let stat = self.resolvedDiff?.stat {
Text(verbatim: "+\(stat.added)")
.font(OpenClawChatTypography.mono(size: 12, relativeTo: .footnote))
.foregroundStyle(OpenClawChatTheme.success.opacity(0.9))
.lineLimit(1)
Text(verbatim: "\(stat.removed)")
.font(OpenClawChatTypography.mono(size: 12, relativeTo: .footnote))
.foregroundStyle(OpenClawChatTheme.danger.opacity(0.9))
.lineLimit(1)
}
Spacer(minLength: 0)
}
.padding(.vertical, 3)
}
private var diffRows: some View {
// The orthogonal nested scroll keeps long diff lines reachable without
// competing with the transcript's vertical gesture.
ScrollView(.horizontal, showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
ForEach(self.expandedDiffLines.indices, id: \.self) { index in
self.diffRow(self.expandedDiffLines[index])
}
}
.textSelection(.enabled)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private func diffRow(_ line: ChatToolDiffLine) -> some View {
if line.kind == .file {
Text(verbatim: String(line.text.unicodeScalars.prefix(2000)))
.font(OpenClawChatTypography.mono(size: 11, relativeTo: .caption))
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.fixedSize(horizontal: true, vertical: false)
.padding(.top, 6)
} else if line.kind == .skip {
Text("")
.font(OpenClawChatTypography.mono(size: 12, relativeTo: .footnote))
.foregroundStyle(.secondary.opacity(0.6))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 2)
// Reuses the existing localized "Collapsed" key so omitted
// preview rows stay announced to assistive tech.
.accessibilityLabel(Text("Collapsed"))
} else {
HStack(spacing: 6) {
if let lineNo = line.lineNo {
Text(verbatim: "\(lineNo)")
.font(OpenClawChatTypography.mono(size: 10, relativeTo: .caption2))
.foregroundStyle(.secondary.opacity(0.6))
.frame(minWidth: 34, alignment: .trailing)
}
// Bound per-line render work; generated/minified payloads can put
// megabytes on a single line.
Text(verbatim: String(line.text.unicodeScalars.prefix(2000)))
.font(OpenClawChatTypography.mono(size: 12, relativeTo: .footnote))
.foregroundStyle(self.diffTextColor(line.kind))
.fixedSize(horizontal: true, vertical: false)
}
.background(self.diffBackground(line.kind))
// Color alone must not carry add/del semantics for assistive tech.
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(verbatim: Self.accessibilityText(for: line)))
}
}
private static func accessibilityText(for line: ChatToolDiffLine) -> String {
let lineNo = line.lineNo.map { "\($0) " } ?? ""
return lineNo + self.accessibilityMarker(line.kind) + String(line.text.unicodeScalars.prefix(2000))
}
private static func accessibilityMarker(_ kind: ChatToolDiffLineKind) -> String {
switch kind {
case .add:
"+ "
case .del:
"\u{2212} "
case .ctx, .file, .skip:
""
}
}
private func diffTextColor(_ kind: ChatToolDiffLineKind) -> Color {
switch kind {
case .add:
OpenClawChatTheme.assistantText
case .del, .ctx, .file, .skip:
.secondary
}
}
private func diffBackground(_ kind: ChatToolDiffLineKind) -> Color {
switch kind {
case .add:
OpenClawChatTheme.success.opacity(0.14)
case .del:
OpenClawChatTheme.danger.opacity(0.12)
case .ctx, .file, .skip:
.clear
}
}
private static func symbol(forToolName name: String?) -> String {
let normalized = name?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
let exact: [String: String] = [
"agent": "rectangle.stack",
"bash": "terminal",
"browser": "safari",
"canvas": "photo",
"clock": "clock",
"command": "terminal",
"cron": "clock",
"create_file": "square.and.pencil",
"edit": "pencil.line",
"edit_file": "pencil.line",
"exec": "terminal",
"fetch": "globe",
"find": "magnifyingglass",
"gateway": "server.rack",
"glob": "magnifyingglass",
"grep": "magnifyingglass",
"image": "photo",
"list": "magnifyingglass",
"ls": "magnifyingglass",
"memory": "brain",
"message": "bubble.left",
"multi_edit": "pencil.line",
"multiedit": "pencil.line",
"notebook_edit": "pencil.line",
"notebookedit": "pencil.line",
"node": "server.rack",
"apply_patch": "pencil.line",
"applypatch": "pencil.line",
"patch": "pencil.line",
"photo": "photo",
"read": "doc.text",
"reply": "bubble.left",
"schedule": "clock",
"screenshot": "photo",
"search": "magnifyingglass",
"send": "bubble.left",
"session": "rectangle.stack",
"shell": "terminal",
"terminal": "terminal",
"web": "globe",
"write": "square.and.pencil",
"write_file": "square.and.pencil",
"str_replace_based_edit_tool": "pencil.line",
"str_replace_editor": "pencil.line",
]
if let symbol = exact[normalized] { return symbol }
let fallbacks: [([String], String)] = [
(["canvas", "image", "screenshot", "photo"], "photo"),
(["browser"], "safari"),
(["message", "send", "reply"], "bubble.left"),
(["node", "gateway"], "server.rack"),
(["cron", "schedule", "clock"], "clock"),
(["memory"], "brain"),
(["session", "agent"], "rectangle.stack"),
(["exec", "bash", "shell", "command", "terminal"], "terminal"),
(["edit", "patch"], "pencil.line"),
(["write"], "square.and.pencil"),
(["grep", "glob", "find", "search", "list"], "magnifyingglass"),
(["read"], "doc.text"),
(["fetch", "web"], "globe"),
]
return fallbacks.first { keys, _ in keys.contains(where: normalized.contains) }?.1
?? "wrench.and.screwdriver"
}
}
struct ChatToolActivityList: View {
let items: [ChatToolActivityItem]
var body: some View {
VStack(alignment: .leading, spacing: 2) {
// Protocol IDs can collide with specified fallback IDs; encounter order is unique here.
ForEach(self.items.indices, id: \.self) { index in
ChatToolActivityRow(item: self.items[index])
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}