diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json
index 8a5ce7acf33e..524da9d7d0bf 100644
--- a/apps/.i18n/native-source.json
+++ b/apps/.i18n/native-source.json
@@ -7259,7 +7259,31 @@
},
{
"kind": "ui-localized-call",
- "line": 100,
+ "line": 97,
+ "path": "apps/ios/Sources/Design/ChatProTab.swift",
+ "source": "Unable to Export Transcript",
+ "surface": "apple",
+ "id": "native.apple.e5524d5756a62744"
+ },
+ {
+ "kind": "ui-call",
+ "line": 101,
+ "path": "apps/ios/Sources/Design/ChatProTab.swift",
+ "source": "OK",
+ "surface": "apple",
+ "id": "native.apple.942d6a10fd90d6e3"
+ },
+ {
+ "kind": "ui-call",
+ "line": 105,
+ "path": "apps/ios/Sources/Design/ChatProTab.swift",
+ "source": "OpenClaw could not prepare the Markdown file.",
+ "surface": "apple",
+ "id": "native.apple.3548c8d1ab43e85e"
+ },
+ {
+ "kind": "ui-localized-call",
+ "line": 125,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "What would you like to work on?",
"surface": "apple",
@@ -7267,7 +7291,7 @@
},
{
"kind": "ui-call",
- "line": 111,
+ "line": 136,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "The session attaches once the gateway is ready.",
"surface": "apple",
@@ -7275,23 +7299,39 @@
},
{
"kind": "ui-modifier",
- "line": 187,
+ "line": 212,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Opens Settings / Gateway",
"surface": "apple",
"id": "native.apple.d19c9577e0fdfea7"
},
{
- "kind": "conditional-branch",
+ "kind": "ui-call",
"line": 237,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
+ "source": "Export Transcript",
+ "surface": "apple",
+ "id": "native.apple.ced228bb4d20a726"
+ },
+ {
+ "kind": "ui-modifier",
+ "line": 247,
+ "path": "apps/ios/Sources/Design/ChatProTab.swift",
+ "source": "Chat actions",
+ "surface": "apple",
+ "id": "native.apple.940e1f943750c94b"
+ },
+ {
+ "kind": "conditional-branch",
+ "line": 300,
+ "path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Connected",
"surface": "apple",
"id": "native.apple.b635acd93246d67b"
},
{
"kind": "conditional-branch",
- "line": 237,
+ "line": 300,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Unavailable",
"surface": "apple",
@@ -7299,7 +7339,7 @@
},
{
"kind": "conditional-branch",
- "line": 248,
+ "line": 311,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Connect to a gateway",
"surface": "apple",
@@ -7307,7 +7347,7 @@
},
{
"kind": "conditional-branch",
- "line": 248,
+ "line": 311,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Message \\(self.agentDisplayName)...",
"surface": "apple",
@@ -7315,7 +7355,7 @@
},
{
"kind": "ui-localized-call",
- "line": 298,
+ "line": 361,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Check OpenClaw status",
"surface": "apple",
@@ -7323,7 +7363,7 @@
},
{
"kind": "ui-localized-call",
- "line": 299,
+ "line": 362,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Summarize the current OpenClaw status and tell me what needs attention.",
"surface": "apple",
@@ -7331,7 +7371,7 @@
},
{
"kind": "ui-localized-call",
- "line": 302,
+ "line": 365,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "What can I control here?",
"surface": "apple",
@@ -7339,7 +7379,7 @@
},
{
"kind": "ui-localized-call",
- "line": 303,
+ "line": 366,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Show me which phone controls and device capabilities are available right now.",
"surface": "apple",
@@ -7347,7 +7387,7 @@
},
{
"kind": "ui-localized-call",
- "line": 306,
+ "line": 369,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Help me start voice chat",
"surface": "apple",
@@ -7355,7 +7395,7 @@
},
{
"kind": "ui-localized-call",
- "line": 307,
+ "line": 370,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Help me start a realtime voice session from this phone.",
"surface": "apple",
diff --git a/apps/ios/Sources/Chat/ChatTranscriptShareSheet.swift b/apps/ios/Sources/Chat/ChatTranscriptShareSheet.swift
new file mode 100644
index 000000000000..14b07af9c264
--- /dev/null
+++ b/apps/ios/Sources/Chat/ChatTranscriptShareSheet.swift
@@ -0,0 +1,12 @@
+import SwiftUI
+import UIKit
+
+struct ChatTranscriptShareSheet: UIViewControllerRepresentable {
+ let fileURL: URL
+
+ func makeUIViewController(context _: Context) -> UIActivityViewController {
+ UIActivityViewController(activityItems: [self.fileURL], applicationActivities: nil)
+ }
+
+ func updateUIViewController(_: UIActivityViewController, context _: Context) {}
+}
diff --git a/apps/ios/Sources/Design/ChatProTab.swift b/apps/ios/Sources/Design/ChatProTab.swift
index c087630fff42..bc056c64e81a 100644
--- a/apps/ios/Sources/Design/ChatProTab.swift
+++ b/apps/ios/Sources/Design/ChatProTab.swift
@@ -3,9 +3,16 @@ import OpenClawProtocol
import SwiftUI
struct ChatProTab: View {
+ private struct TranscriptShareItem: Identifiable {
+ let id = UUID()
+ let fileURL: URL
+ }
+
@Environment(NodeAppModel.self) private var appModel
@State private var viewModel: OpenClawChatViewModel?
@State private var viewModelTransportModeID = ""
+ @State private var transcriptShareItem: TranscriptShareItem?
+ @State private var showsTranscriptExportError = false
let headerLeadingAction: OpenClawSidebarHeaderAction?
let headerTitle: String?
let showsAgentBadge: Bool
@@ -75,11 +82,29 @@ struct ChatProTab: View {
self.headerIdentityBadge
}
}
+ ToolbarItem(placement: .topBarTrailing) {
+ self.chatActionsMenu
+ }
ToolbarItem(placement: .topBarTrailing) {
self.connectionStatusButton
.accessibilityIdentifier("chat-gateway-status")
}
}
+ .sheet(item: self.$transcriptShareItem) { item in
+ ChatTranscriptShareSheet(fileURL: item.fileURL)
+ }
+ .alert(
+ String(localized: "Unable to Export Transcript"),
+ isPresented: self.$showsTranscriptExportError)
+ {
+ Button(role: .cancel) {} label: {
+ Text("OK")
+ .font(OpenClawType.body)
+ }
+ } message: {
+ Text("OpenClaw could not prepare the Markdown file.")
+ .font(OpenClawType.body)
+ }
}
@ViewBuilder
@@ -203,6 +228,44 @@ struct ChatProTab: View {
.padding(.horizontal, 6)
}
+ private var chatActionsMenu: some View {
+ Menu {
+ Button {
+ self.exportTranscript()
+ } label: {
+ Label {
+ Text("Export Transcript")
+ .font(OpenClawType.body)
+ } icon: {
+ Image(systemName: "square.and.arrow.up")
+ }
+ }
+ .disabled(self.viewModel == nil)
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ }
+ .accessibilityLabel("Chat actions")
+ }
+
+ private func exportTranscript() {
+ guard let viewModel else { return }
+ let title = viewModel.sessions.first { $0.key == viewModel.sessionKey }?.displayName
+ let filename = ChatTranscriptExporter.filename(
+ sessionTitle: title,
+ sessionKey: viewModel.sessionKey)
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent("OpenClawTranscripts", isDirectory: true)
+ let fileURL = directory.appendingPathComponent(filename, isDirectory: false)
+
+ do {
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ try viewModel.exportTranscriptMarkdown().write(to: fileURL, atomically: true, encoding: .utf8)
+ self.transcriptShareItem = TranscriptShareItem(fileURL: fileURL)
+ } catch {
+ self.showsTranscriptExportError = true
+ }
+ }
+
private var gatewayConnected: Bool {
guard self.gatewayDisplayState == .connected else {
return false
diff --git a/apps/ios/Tests/OpenClawTypographyTests.swift b/apps/ios/Tests/OpenClawTypographyTests.swift
index 4b0ba4459644..15f35e500237 100644
--- a/apps/ios/Tests/OpenClawTypographyTests.swift
+++ b/apps/ios/Tests/OpenClawTypographyTests.swift
@@ -155,6 +155,7 @@ struct OpenClawTypographyTests {
contentsOf: Self.sourceURL("Design/SettingsChannelsDestination.swift"),
encoding: .utf8)
let docs = try String(contentsOf: Self.sourceURL("Design/OpenClawDocsScreen.swift"), encoding: .utf8)
+ let chatTab = try String(contentsOf: Self.sourceURL("Design/ChatProTab.swift"), encoding: .utf8)
let chatTypography = try String(
contentsOf: Self.iosRootURL()
.deletingLastPathComponent()
@@ -172,6 +173,10 @@ struct OpenClawTypographyTests {
#expect(proComponents.contains("@Environment(\\.isEnabled)"))
#expect(proComponents.contains(".animation(.easeOut(duration: 0.15), value: self.isEnabled)"))
+ #expect(chatTab.contains("Text(\"Export Transcript\")"))
+ #expect(chatTab.contains(".font(OpenClawType.body)"))
+ #expect(!chatTab.contains("Button(\"Export Transcript\")"))
+
#expect(!quickSetup.contains("Button(\"Close\")"))
#expect(quickSetup.contains("Text(\"Quick Setup\")"))
#expect(quickSetup.contains("Text(\"Close\")"))
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptExporter.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptExporter.swift
new file mode 100644
index 000000000000..7ef938afb582
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptExporter.swift
@@ -0,0 +1,162 @@
+import Foundation
+
+public enum ChatTranscriptExporter {
+ private static let maxFileStemUTF8Count = 240
+
+ public static func markdown(
+ sessionTitle: String?,
+ sessionKey: String,
+ messages: [OpenClawChatMessage]) -> String
+ {
+ let title = self.resolvedTitle(sessionTitle: sessionTitle, sessionKey: sessionKey)
+ let timestampFormatter = ISO8601DateFormatter()
+ timestampFormatter.formatOptions = [.withInternetDateTime]
+ timestampFormatter.timeZone = TimeZone(secondsFromGMT: 0)
+
+ var sections = ["# \(title)"]
+ for message in messages where self.shouldExport(message) {
+ let timestamp = self.timestamp(message.timestamp, formatter: timestampFormatter)
+ let heading = "### \(self.displayRole(message.role)) — \(timestamp)"
+ let body = self.body(for: message)
+ sections.append([heading, body].filter { !$0.isEmpty }.joined(separator: "\n\n"))
+ }
+ return sections.joined(separator: "\n\n") + "\n"
+ }
+
+ public static func filename(sessionTitle: String?, sessionKey: String) -> String {
+ let title = sessionTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ let stem = self.sanitizedFileStem(title)
+ ?? self.sanitizedFileStem(key)
+ ?? "chat-transcript"
+ let boundedStem = self.boundedFileStem(stem)
+ return "\(boundedStem.isEmpty ? "chat-transcript" : boundedStem).md"
+ }
+
+ private static func sanitizedFileStem(_ value: String) -> String? {
+ let forbidden = CharacterSet(charactersIn: "/\\:*?\"<>|").union(.controlCharacters)
+ var segments: [String] = []
+ var current = ""
+
+ for scalar in value.unicodeScalars {
+ if forbidden.contains(scalar) {
+ segments.append(current)
+ current = ""
+ } else {
+ current.unicodeScalars.append(scalar)
+ }
+ }
+ segments.append(current)
+
+ let edgeCharacters = CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: ".-"))
+ let stem = segments
+ .map { segment in
+ segment
+ .split(whereSeparator: { $0.isWhitespace })
+ .joined(separator: " ")
+ .trimmingCharacters(in: edgeCharacters)
+ }
+ .filter { !$0.isEmpty }
+ .joined(separator: "-")
+ return stem.isEmpty ? nil : stem
+ }
+
+ private static func boundedFileStem(_ stem: String) -> String {
+ var result = ""
+ for character in stem {
+ let candidate = result + String(character)
+ guard candidate.utf8.count <= self.maxFileStemUTF8Count else { break }
+ result = candidate
+ }
+ return result
+ }
+
+ private static func resolvedTitle(sessionTitle: String?, sessionKey: String) -> String {
+ let title = sessionTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ if !title.isEmpty {
+ return title.split(whereSeparator: { $0.isNewline }).joined(separator: " ")
+ }
+ let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ return key.isEmpty ? "Chat transcript" : key.split(whereSeparator: { $0.isNewline }).joined(separator: " ")
+ }
+
+ private static func shouldExport(_ message: OpenClawChatMessage) -> Bool {
+ let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard role != "system" else { return false }
+ if !self.attachments(in: message).isEmpty {
+ return true
+ }
+ let text = self.visibleText(in: message)
+ guard !text.isEmpty else { return false }
+ return role == "user" || AssistantTextParser.hasVisibleContent(in: text)
+ }
+
+ private static func body(for message: OpenClawChatMessage) -> String {
+ var parts: [String] = []
+ let text = self.visibleText(in: message)
+ if !text.isEmpty {
+ parts.append(text)
+ }
+ parts.append(contentsOf: self.attachments(in: message).map { attachment in
+ let filename = attachment.fileName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ return "_[attachment: \(filename.isEmpty ? "Attachment" : filename)]_"
+ })
+ return parts.joined(separator: "\n\n")
+ }
+
+ private static func primaryText(in message: OpenClawChatMessage) -> String {
+ let parts = message.content.compactMap { content -> String? in
+ let kind = (content.type ?? "text").lowercased()
+ guard kind == "text" || kind.isEmpty else { return nil }
+ return content.text
+ }
+ return OpenClawChatMessage.displayText(
+ contentText: parts.joined(separator: "\n"),
+ role: message.role,
+ stopReason: message.stopReason,
+ errorMessage: message.errorMessage)
+ }
+
+ private static func visibleText(in message: OpenClawChatMessage) -> String {
+ let text = self.primaryText(in: message)
+ let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard role != "user" else { return text }
+ return AssistantTextParser.visibleSegments(from: text)
+ .map(\.text)
+ .joined(separator: "\n\n")
+ }
+
+ private static func attachments(in message: OpenClawChatMessage) -> [OpenClawChatMessageContent] {
+ message.content.filter { content in
+ let kind = (content.type ?? "text").lowercased()
+ return kind == "file" || kind == "attachment"
+ }
+ }
+
+ private static func displayRole(_ role: String) -> String {
+ let normalized = role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ return switch normalized {
+ case "user": "User"
+ case "assistant": "Assistant"
+ default:
+ normalized.isEmpty
+ ? "Message"
+ : normalized.prefix(1).uppercased() + normalized.dropFirst()
+ }
+ }
+
+ private static func timestamp(_ value: Double?, formatter: ISO8601DateFormatter) -> String {
+ guard let value else { return "Unknown time" }
+ return formatter.string(from: Date(timeIntervalSince1970: value / 1000))
+ }
+}
+
+extension OpenClawChatViewModel {
+ public func exportTranscriptMarkdown() -> String {
+ let title = self.sessions.first { $0.key == self.sessionKey }?.displayName
+ return ChatTranscriptExporter.markdown(
+ sessionTitle: title,
+ sessionKey: self.sessionKey,
+ messages: self.messages)
+ }
+}
diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptExporterTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptExporterTests.swift
new file mode 100644
index 000000000000..a52cc93f3bd3
--- /dev/null
+++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptExporterTests.swift
@@ -0,0 +1,105 @@
+import Testing
+@testable import OpenClawChatUI
+
+@Suite("ChatTranscriptExporter")
+struct ChatTranscriptExporterTests {
+ @Test func `formats visible messages and attachments`() {
+ let messages = [
+ self.message(role: "system", text: "Hidden setup", timestamp: 0),
+ self.message(role: "user", text: "Hello, 世界 👋", timestamp: 0),
+ self.message(
+ role: "assistant",
+ text: """
+ Do not export this reasoning.
+ | Item | Value |
+ | --- | --- |
+ | code | `ok` |
+
+ ```swift
+ print("hi")
+ ```
+
+ """,
+ timestamp: 1000),
+ OpenClawChatMessage(
+ role: "user",
+ content: [
+ OpenClawChatMessageContent(
+ type: "attachment",
+ text: nil,
+ mimeType: "text/plain",
+ fileName: "résumé.txt",
+ content: nil),
+ ],
+ timestamp: 2000),
+ self.message(role: "assistant", text: " ", timestamp: 3000),
+ ]
+
+ let markdown = ChatTranscriptExporter.markdown(
+ sessionTitle: "Project 🐾",
+ sessionKey: "agent:main",
+ messages: messages)
+
+ #expect(markdown == """
+ # Project 🐾
+
+ ### User — 1970-01-01T00:00:00Z
+
+ Hello, 世界 👋
+
+ ### Assistant — 1970-01-01T00:00:01Z
+
+ | Item | Value |
+ | --- | --- |
+ | code | `ok` |
+
+ ```swift
+ print("hi")
+ ```
+
+ ### User — 1970-01-01T00:00:02Z
+
+ _[attachment: résumé.txt]_
+
+ """)
+ }
+
+ @Test func `sanitizes filename`() {
+ #expect(
+ ChatTranscriptExporter.filename(
+ sessionTitle: " Project / Q3: \"Résumé\"? ",
+ sessionKey: "fallback") == "Project-Q3-Résumé.md")
+ #expect(
+ ChatTranscriptExporter.filename(
+ sessionTitle: "///",
+ sessionKey: "fallback") == "fallback.md")
+
+ let emojiFilename = ChatTranscriptExporter.filename(
+ sessionTitle: String(repeating: "🐾", count: 200),
+ sessionKey: "fallback")
+ #expect(emojiFilename.utf8.count <= 243)
+ #expect(emojiFilename.hasSuffix(".md"))
+ }
+
+ @Test func `empty transcript contains header only`() {
+ #expect(
+ ChatTranscriptExporter.markdown(
+ sessionTitle: nil,
+ sessionKey: "agent:main",
+ messages: []) == "# agent:main\n")
+ }
+
+ private func message(role: String, text: String, timestamp: Double) -> OpenClawChatMessage {
+ OpenClawChatMessage(
+ role: role,
+ content: [
+ OpenClawChatMessageContent(
+ type: "text",
+ text: text,
+ mimeType: nil,
+ fileName: nil,
+ content: nil),
+ ],
+ timestamp: timestamp)
+ }
+}