mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-23 09:51:14 +00:00
* refactor(chat-ui): render tool calls as flat expandable rows Replace the nested ToolCallCard/ToolResultCard/pending-tool cards inside assistant bubbles with flat rows that mirror the web Control UI: chevron + kind icon + title + one-line mono detail, result folded into the row and revealed on tap (Show all for long outputs). Tool-only messages no longer render bubble chrome. * chore(i18n): sync native inventory + translations for tool-row strings
262 lines
9.6 KiB
Swift
262 lines
9.6 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 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,
|
|
resultText: result?.text,
|
|
isPending: false)
|
|
}
|
|
|
|
items.append(contentsOf: remainingResults.map { index, result in
|
|
ChatToolActivityItem(
|
|
id: result.id ?? "result-\(index)",
|
|
name: result.name,
|
|
arguments: nil,
|
|
resultText: result.text,
|
|
isPending: false)
|
|
})
|
|
return items
|
|
}
|
|
}
|
|
|
|
struct ChatToolActivityRow: View {
|
|
let item: ChatToolActivityItem
|
|
@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.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 resultLineCount: Int {
|
|
self.formattedResult.components(separatedBy: .newlines).count
|
|
}
|
|
|
|
private var isResultTruncated: Bool {
|
|
self.resultLineCount > 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…"
|
|
}
|
|
|
|
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) {
|
|
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.resultLineCount)))
|
|
.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)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.vertical, 3)
|
|
}
|
|
|
|
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",
|
|
"edit": "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",
|
|
"node": "server.rack",
|
|
"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",
|
|
]
|
|
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)
|
|
}
|
|
}
|