iOS Chat: clean UI noise and format tool outputs (#22122)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 34dd87b0c0
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-02-20 19:01:03 +00:00
committed by GitHub
parent 5828708343
commit 9476dda9f6
8 changed files with 369 additions and 52 deletions

View File

@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Channels/CLI: add per-account/channel `defaultTo` outbound routing fallback so `openclaw agent --deliver` can send without explicit `--reply-to` when a default target is configured. (#16985) Thanks @KirillShchetinin.
- iOS/Chat: clean chat UI noise by stripping inbound untrusted metadata/timestamp prefixes, formatting tool outputs into concise summaries/errors, compacting the composer while typing, and supporting tap-to-dismiss keyboard in chat view. (#22122) thanks @mbelinky.
- iOS/Tests: cover IPv4-mapped IPv6 loopback in manual TLS policy tests for connect validation paths. (#22045) Thanks @mbelinky.
- iOS/Gateway: stabilize background wake and reconnect behavior with background reconnect suppression/lease windows, BGAppRefresh wake fallback, location wake hook throttling, and APNs wake retry+nudge instrumentation. (#21226) thanks @mbelinky.
- Auto-reply/UI: add model fallback lifecycle visibility in verbose logs, /status active-model context with fallback reason, and cohesive WebUI fallback indicators. (#20704) Thanks @joshavant.

View File

@@ -180,10 +180,12 @@ struct OpenClawChatComposer: View {
VStack(alignment: .leading, spacing: 8) {
self.editorOverlay
Rectangle()
.fill(OpenClawChatTheme.divider)
.frame(height: 1)
.padding(.horizontal, 2)
if !self.isComposerCompacted {
Rectangle()
.fill(OpenClawChatTheme.divider)
.frame(height: 1)
.padding(.horizontal, 2)
}
HStack(alignment: .center, spacing: 8) {
if self.showsConnectionPill {
@@ -308,7 +310,7 @@ struct OpenClawChatComposer: View {
}
private var showsToolbar: Bool {
self.style == .standard
self.style == .standard && !self.isComposerCompacted
}
private var showsAttachments: Bool {
@@ -316,15 +318,15 @@ struct OpenClawChatComposer: View {
}
private var showsConnectionPill: Bool {
self.style == .standard
self.style == .standard && !self.isComposerCompacted
}
private var composerPadding: CGFloat {
self.style == .onboarding ? 5 : 6
self.style == .onboarding ? 5 : (self.isComposerCompacted ? 4 : 6)
}
private var editorPadding: CGFloat {
self.style == .onboarding ? 5 : 6
self.style == .onboarding ? 5 : (self.isComposerCompacted ? 4 : 6)
}
private var textMinHeight: CGFloat {
@@ -335,6 +337,14 @@ struct OpenClawChatComposer: View {
self.style == .onboarding ? 52 : 64
}
private var isComposerCompacted: Bool {
#if os(macOS)
false
#else
self.style == .standard && self.isFocused
#endif
}
#if os(macOS)
private func pickFilesMac() {
let panel = NSOpenPanel()

View File

@@ -1,6 +1,15 @@
import Foundation
enum ChatMarkdownPreprocessor {
private static let inboundContextHeaders = [
"Conversation info (untrusted metadata):",
"Sender (untrusted metadata):",
"Thread starter (untrusted, for context):",
"Replied message (untrusted, for context):",
"Forwarded message context (untrusted metadata):",
"Chat history since last reply (untrusted, for context):",
]
struct InlineImage: Identifiable {
let id = UUID()
let label: String
@@ -13,17 +22,21 @@ enum ChatMarkdownPreprocessor {
}
static func preprocess(markdown raw: String) -> Result {
let withoutContextBlocks = self.stripInboundContextBlocks(raw)
let withoutTimestamps = self.stripPrefixedTimestamps(withoutContextBlocks)
let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"#
guard let re = try? NSRegularExpression(pattern: pattern) else {
return Result(cleaned: raw, images: [])
return Result(cleaned: self.normalize(withoutTimestamps), images: [])
}
let ns = raw as NSString
let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length))
if matches.isEmpty { return Result(cleaned: raw, images: []) }
let ns = withoutTimestamps as NSString
let matches = re.matches(
in: withoutTimestamps,
range: NSRange(location: 0, length: ns.length))
if matches.isEmpty { return Result(cleaned: self.normalize(withoutTimestamps), images: []) }
var images: [InlineImage] = []
var cleaned = raw
var cleaned = withoutTimestamps
for match in matches.reversed() {
guard match.numberOfRanges >= 3 else { continue }
@@ -43,9 +56,32 @@ enum ChatMarkdownPreprocessor {
cleaned.replaceSubrange(start..<end, with: "")
}
let normalized = cleaned
.replacingOccurrences(of: "\n\n\n", with: "\n\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
return Result(cleaned: normalized, images: images.reversed())
return Result(cleaned: self.normalize(cleaned), images: images.reversed())
}
private static func stripInboundContextBlocks(_ raw: String) -> String {
var output = raw
for header in self.inboundContextHeaders {
let escaped = NSRegularExpression.escapedPattern(for: header)
let pattern = "(?ms)^" + escaped + "\\n```json\\n.*?\\n```\\n?"
output = output.replacingOccurrences(
of: pattern,
with: "",
options: .regularExpression)
}
return output
}
private static func stripPrefixedTimestamps(_ raw: String) -> String {
let pattern = #"(?m)^\[[A-Za-z]{3}\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?\s+(?:GMT|UTC)[+-]?\d{0,2}\]\s*"#
return raw.replacingOccurrences(of: pattern, with: "", options: .regularExpression)
}
private static func normalize(_ raw: String) -> String {
var output = raw
output = output.replacingOccurrences(of: "\r\n", with: "\n")
output = output.replacingOccurrences(of: "\n\n\n", with: "\n\n")
output = output.replacingOccurrences(of: "\n\n\n", with: "\n\n")
return output.trimmingCharacters(in: .whitespacesAndNewlines)
}
}

View File

@@ -173,7 +173,8 @@ private struct ChatMessageBody: View {
ToolResultCard(
title: self.toolResultTitle,
text: text,
isUser: self.isUser)
isUser: self.isUser,
toolName: self.message.toolName)
}
} else if self.isUser {
ChatMarkdownRenderer(
@@ -207,7 +208,8 @@ private struct ChatMessageBody: View {
ToolResultCard(
title: "\(display.emoji) \(display.title)",
text: toolResult.text ?? "",
isUser: self.isUser)
isUser: self.isUser,
toolName: toolResult.name)
}
}
}
@@ -402,47 +404,54 @@ private struct ToolResultCard: View {
let title: String
let text: String
let isUser: Bool
let toolName: String?
@State private var expanded = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Text(self.title)
.font(.footnote.weight(.semibold))
Spacer(minLength: 0)
}
Text(self.displayText)
.font(.footnote.monospaced())
.foregroundStyle(self.isUser ? OpenClawChatTheme.userText : OpenClawChatTheme.assistantText)
.lineLimit(self.expanded ? nil : Self.previewLineLimit)
if self.shouldShowToggle {
Button(self.expanded ? "Show less" : "Show full output") {
self.expanded.toggle()
if !self.displayContent.isEmpty {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Text(self.title)
.font(.footnote.weight(.semibold))
Spacer(minLength: 0)
}
Text(self.displayText)
.font(.footnote.monospaced())
.foregroundStyle(self.isUser ? OpenClawChatTheme.userText : OpenClawChatTheme.assistantText)
.lineLimit(self.expanded ? nil : Self.previewLineLimit)
if self.shouldShowToggle {
Button(self.expanded ? "Show less" : "Show full output") {
self.expanded.toggle()
}
.buttonStyle(.plain)
.font(.caption)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(OpenClawChatTheme.subtleCard)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1)))
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(OpenClawChatTheme.subtleCard)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1)))
}
private static let previewLineLimit = 8
private var displayContent: String {
ToolResultTextFormatter.format(text: self.text, toolName: self.toolName)
}
private var lines: [Substring] {
self.text.components(separatedBy: .newlines).map { Substring($0) }
self.displayContent.components(separatedBy: .newlines).map { Substring($0) }
}
private var displayText: String {
guard !self.expanded, self.lines.count > Self.previewLineLimit else { return self.text }
guard !self.expanded, self.lines.count > Self.previewLineLimit else { return self.displayContent }
return self.lines.prefix(Self.previewLineLimit).joined(separator: "\n") + "\n"
}
@@ -458,12 +467,7 @@ struct ChatTypingIndicatorBubble: View {
var body: some View {
HStack(spacing: 10) {
TypingDots()
if self.style == .standard {
Text("OpenClaw is thinking…")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
}
Spacer(minLength: 0)
}
.padding(.vertical, self.style == .standard ? 12 : 10)
.padding(.horizontal, self.style == .standard ? 12 : 14)

View File

@@ -1,4 +1,7 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
@MainActor
public struct OpenClawChatView: View {
@@ -105,6 +108,9 @@ public struct OpenClawChatView: View {
.padding(.top, Layout.messageListPaddingTop)
.padding(.horizontal, Layout.messageListPaddingHorizontal)
}
#if !os(macOS)
.scrollDismissesKeyboard(.interactively)
#endif
// Keep the scroll pinned to the bottom for new messages.
.scrollPosition(id: self.$scrollPosition, anchor: .bottom)
.onChange(of: self.scrollPosition) { _, position in
@@ -123,6 +129,10 @@ public struct OpenClawChatView: View {
// Ensure the message list claims vertical space on the first layout pass.
.frame(maxHeight: .infinity, alignment: .top)
.layoutPriority(1)
.simultaneousGesture(
TapGesture().onEnded {
self.dismissKeyboardIfNeeded()
})
.onChange(of: self.viewModel.isLoading) { _, isLoading in
guard !isLoading, !self.hasPerformedInitialScroll else { return }
self.scrollPosition = self.scrollerBottomID
@@ -406,6 +416,16 @@ public struct OpenClawChatView: View {
}
return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
}
private func dismissKeyboardIfNeeded() {
#if canImport(UIKit)
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil,
from: nil,
for: nil)
#endif
}
}
private struct ChatNoticeCard: View {

View File

@@ -0,0 +1,157 @@
import Foundation
enum ToolResultTextFormatter {
static func format(text: String, toolName: String?) -> String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "" }
guard self.looksLikeJSON(trimmed),
let data = trimmed.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data)
else {
return trimmed
}
let normalizedTool = toolName?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return self.renderJSON(json, toolName: normalizedTool)
}
private static func looksLikeJSON(_ value: String) -> Bool {
guard let first = value.first else { return false }
return first == "{" || first == "["
}
private static func renderJSON(_ json: Any, toolName: String?) -> String {
if let dict = json as? [String: Any] {
return self.renderDictionary(dict, toolName: toolName)
}
if let array = json as? [Any] {
if array.isEmpty { return "No items." }
return "\(array.count) item\(array.count == 1 ? "" : "s")."
}
return ""
}
private static func renderDictionary(_ dict: [String: Any], toolName: String?) -> String {
let status = (dict["status"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let errorText = self.firstString(in: dict, keys: ["error", "reason"])
let messageText = self.firstString(in: dict, keys: ["message", "result", "detail"])
if status?.lowercased() == "error" || errorText != nil {
if let errorText {
return "Error: \(self.sanitizeError(errorText))"
}
if let messageText {
return "Error: \(self.sanitizeError(messageText))"
}
return "Error"
}
if toolName == "nodes", let summary = self.renderNodesSummary(dict) {
return summary
}
if let message = messageText {
return message
}
if let status, !status.isEmpty {
return "Status: \(status)"
}
return ""
}
private static func renderNodesSummary(_ dict: [String: Any]) -> String? {
if let nodes = dict["nodes"] as? [[String: Any]] {
if nodes.isEmpty { return "No nodes found." }
var lines: [String] = []
lines.append("\(nodes.count) node\(nodes.count == 1 ? "" : "s") found.")
for node in nodes.prefix(3) {
let label = self.firstString(in: node, keys: ["displayName", "name", "nodeId"]) ?? "Node"
var details: [String] = []
if let connected = node["connected"] as? Bool {
details.append(connected ? "connected" : "offline")
}
if let platform = self.firstString(in: node, keys: ["platform"]) {
details.append(platform)
}
if let version = self.firstString(in: node, keys: ["osVersion", "appVersion", "version"]) {
details.append(version)
}
if let pairing = self.pairingDetail(node) {
details.append(pairing)
}
if details.isEmpty {
lines.append("\(label)")
} else {
lines.append("\(label) - \(details.joined(separator: ", "))")
}
}
let extra = nodes.count - 3
if extra > 0 {
lines.append("... +\(extra) more")
}
return lines.joined(separator: "\n")
}
if let pending = dict["pending"] as? [Any], let paired = dict["paired"] as? [Any] {
return "Pairing requests: \(pending.count) pending, \(paired.count) paired."
}
if let pending = dict["pending"] as? [Any] {
if pending.isEmpty { return "No pending pairing requests." }
return "\(pending.count) pending pairing request\(pending.count == 1 ? "" : "s")."
}
return nil
}
private static func pairingDetail(_ node: [String: Any]) -> String? {
if let paired = node["paired"] as? Bool, !paired {
return "pairing required"
}
for key in ["status", "state", "deviceStatus"] {
if let raw = node[key] as? String, raw.lowercased().contains("pairing required") {
return "pairing required"
}
}
return nil
}
private static func firstString(in dict: [String: Any], keys: [String]) -> String? {
for key in keys {
if let value = dict[key] as? String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
}
}
return nil
}
private static func sanitizeError(_ raw: String) -> String {
var cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if cleaned.contains("agent="),
cleaned.contains("action="),
let marker = cleaned.range(of: ": ")
{
cleaned = String(cleaned[marker.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
}
if let firstLine = cleaned.split(separator: "\n").first {
cleaned = String(firstLine).trimmingCharacters(in: .whitespacesAndNewlines)
}
if cleaned.count > 220 {
cleaned = String(cleaned.prefix(217)) + "..."
}
return cleaned
}
}

View File

@@ -17,4 +17,39 @@ struct ChatMarkdownPreprocessorTests {
#expect(result.images.count == 1)
#expect(result.images.first?.image != nil)
}
@Test func stripsInboundUntrustedContextBlocks() {
let markdown = """
Conversation info (untrusted metadata):
```json
{
"message_id": "123",
"sender": "openclaw-ios"
}
```
Sender (untrusted metadata):
```json
{
"label": "Razor"
}
```
Razor?
"""
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
#expect(result.cleaned == "Razor?")
}
@Test func stripsLeadingTimestampPrefix() {
let markdown = """
[Fri 2026-02-20 18:45 GMT+1] How's it going?
"""
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
#expect(result.cleaned == "How's it going?")
}
}

View File

@@ -0,0 +1,54 @@
import Testing
@testable import OpenClawChatUI
@Suite("ToolResultTextFormatter")
struct ToolResultTextFormatterTests {
@Test func leavesPlainTextUntouched() {
let result = ToolResultTextFormatter.format(text: "All good", toolName: "nodes")
#expect(result == "All good")
}
@Test func summarizesNodesListJSON() {
let json = """
{
"ts": 1771610031380,
"nodes": [
{
"displayName": "iPhone 16 Pro Max",
"connected": true,
"platform": "ios"
}
]
}
"""
let result = ToolResultTextFormatter.format(text: json, toolName: "nodes")
#expect(result.contains("1 node found."))
#expect(result.contains("iPhone 16 Pro Max"))
#expect(result.contains("connected"))
}
@Test func summarizesErrorJSONAndDropsAgentPrefix() {
let json = """
{
"status": "error",
"tool": "nodes",
"error": "agent=main node=iPhone gateway=default action=invoke: pairing required"
}
"""
let result = ToolResultTextFormatter.format(text: json, toolName: "nodes")
#expect(result == "Error: pairing required")
}
@Test func suppressesUnknownStructuredPayload() {
let json = """
{
"foo": "bar"
}
"""
let result = ToolResultTextFormatter.format(text: json, toolName: "nodes")
#expect(result.isEmpty)
}
}