mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 22:51:15 +00:00
* 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
414 lines
16 KiB
Swift
414 lines
16 KiB
Swift
// 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)
|
||
}
|
||
}
|