Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift
Peter Steinberger 3a0d6b327b feat(chat-ui): inline diffs for edit and write tool rows (#111039)
* feat(chat-ui): inline diffs for edit and write tool rows

Port the web Control UI's kind-aware diff rendering to the shared SwiftUI
tool rows: persisted details.diff parses into numbered add/del/ctx lines,
edit/write/text-editor args fall back to a local capped line diff, the
collapsed row shows a +N/-M diffstat, and the expanded surface renders the
diff with the result summary or error diagnostic beneath it.

* fix(chat-ui): preserve tool-result details through message reconciliation

Canonical-adoption rebuilds enumerated message fields and silently dropped
details, so inline diffs vanished after cache-warm history reconciliation
(every app relaunch). Carry details through adoptingCanonicalMessage and
messageWithTimestampIfNeeded and lock it in with regression tests.

* chore(i18n): sync native inventory for tool diff rows

* fix(chat-ui): render diffstat and line numbers as verbatim text

* fix(chat-ui): use theme tokens for diff colors

* refactor(chat-ui): drop test-only parseDetailsDiff wrapper

* fix(chat-ui): address diff-row review findings

Interpret details.diff only for edit/write/patch tool names so plugin
detail schemas stay opaque; number write previews from line 1 with an
80-line cap and a subtractive char budget while keeping the exact stat;
bound per-line rendering to 2000 unicode scalars; announce add/del rows
and collapsed skip markers to assistive tech.

* chore(i18n): sync native artifacts for diff-row labels

* fix(chat-ui): announce diff line numbers to assistive tech
2026-07-19 01:21:51 -07:00

400 lines
15 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 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,
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,
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)
}
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(.secondary)
Text(self.display.title)
.font(OpenClawChatTypography.footnoteSemiBold)
.foregroundStyle(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 {
VStack(alignment: .leading, spacing: 0) {
ForEach(self.expandedDiffLines.indices, id: \.self) { index in
self.diffRow(self.expandedDiffLines[index])
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
@ViewBuilder
private func diffRow(_ line: ChatToolDiffLine) -> some View {
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))
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
}
.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, .skip:
""
}
}
private func diffTextColor(_ kind: ChatToolDiffLineKind) -> Color {
switch kind {
case .add:
OpenClawChatTheme.assistantText
case .del, .ctx, .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, .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)
}
}