fix(native): keep images and voice notes visible in chat and exports (#113764)

* fix(native): preserve image and audio in chat transcripts

* fix(native): refresh extracted media localization source lines

---------

Co-authored-by: Peter Steinberger <steipete@golden-gate.local>
This commit is contained in:
Peter Steinberger 2026-07-25 10:49:56 -07:00 committed by GitHub
parent fe4972b12b
commit a1d666fdc3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 154 additions and 36 deletions

View file

@ -40003,7 +40003,7 @@
},
{
"kind": "conditional-branch",
"line": 635,
"line": 628,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Voice note",
"surface": "apple",
@ -40011,7 +40011,7 @@
},
{
"kind": "ui-call",
"line": 692,
"line": 685,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Writing",
"surface": "apple",
@ -40019,7 +40019,7 @@
},
{
"kind": "ui-call",
"line": 726,
"line": 719,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Preparing audio…",
"surface": "apple",
@ -40027,7 +40027,7 @@
},
{
"kind": "ui-call",
"line": 729,
"line": 722,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Speaking…",
"surface": "apple",
@ -40035,7 +40035,7 @@
},
{
"kind": "conditional-branch",
"line": 737,
"line": 730,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Preparing audio, tap to cancel",
"surface": "apple",
@ -40043,7 +40043,7 @@
},
{
"kind": "conditional-branch",
"line": 738,
"line": 731,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Speaking, tap to stop",
"surface": "apple",
@ -41219,7 +41219,7 @@
},
{
"kind": "conditional-branch",
"line": 125,
"line": 122,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptExporter.swift",
"source": "Message",
"surface": "apple",
@ -41275,7 +41275,7 @@
},
{
"kind": "ui-call",
"line": 1145,
"line": 1138,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Copy Message",
"surface": "apple",
@ -41283,7 +41283,7 @@
},
{
"kind": "ui-call",
"line": 1168,
"line": 1161,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Open Full Message",
"surface": "apple",
@ -41291,7 +41291,7 @@
},
{
"kind": "ui-call",
"line": 1187,
"line": 1180,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Rewind to Here",
"surface": "apple",
@ -41299,7 +41299,7 @@
},
{
"kind": "ui-call",
"line": 1207,
"line": 1200,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Fork from Here",
"surface": "apple",
@ -41307,7 +41307,7 @@
},
{
"kind": "ui-localized-call",
"line": 1233,
"line": 1226,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Reply",
"surface": "apple",
@ -41315,7 +41315,7 @@
},
{
"kind": "ui-localized-call",
"line": 1243,
"line": 1236,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "You",
"surface": "apple",
@ -41323,7 +41323,7 @@
},
{
"kind": "ui-localized-call",
"line": 1245,
"line": 1238,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Assistant",
"surface": "apple",
@ -41331,7 +41331,7 @@
},
{
"kind": "ui-call",
"line": 1311,
"line": 1304,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Loading chat",
"surface": "apple",
@ -41339,7 +41339,7 @@
},
{
"kind": "ui-modifier",
"line": 1391,
"line": 1384,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Dismiss",
"surface": "apple",

View file

@ -501,14 +501,7 @@ private struct ChatMessageBody: View {
}
private var inlineAttachments: [OpenClawChatMessageContent] {
self.message.content.filter { content in
switch content.type ?? "text" {
case "file", "attachment":
true
default:
false
}
}
self.message.content.filter(\.isInlineAttachment)
}
private var inlineWidgets: [OpenClawChatCanvasPreview] {

View file

@ -127,6 +127,16 @@ public struct OpenClawChatMessageContent: Codable, Hashable, Sendable {
public let details: AnyCodable?
public let isError: Bool?
/// Gateway media and historical file attachments must stay visible in both chat and exports.
var isInlineAttachment: Bool {
switch self.type?.lowercased() {
case "file", "attachment", "image", "audio":
true
default:
false
}
}
public init(
type: String?,
text: String?,

View file

@ -109,10 +109,7 @@ public enum ChatTranscriptExporter {
}
private static func attachments(in message: OpenClawChatMessage) -> [OpenClawChatMessageContent] {
message.content.filter { content in
let kind = (content.type ?? "text").lowercased()
return kind == "file" || kind == "attachment"
}
message.content.filter(\.isInlineAttachment)
}
private static func displayRole(_ role: String) -> String {

View file

@ -1090,14 +1090,7 @@ extension OpenClawChatView {
}
private func hasInlineAttachments(in message: OpenClawChatMessage) -> Bool {
message.content.contains { content in
switch content.type ?? "text" {
case "file", "attachment":
true
default:
false
}
}
message.content.contains(where: \.isInlineAttachment)
}
private func toolCalls(in message: OpenClawChatMessage) -> [OpenClawChatMessageContent] {

View file

@ -64,6 +64,131 @@ struct ChatTranscriptExporterTests {
""")
}
@Test(arguments: ["file", "attachment", "image", "audio", "FILE", "ATTACHMENT", "IMAGE", "AuDiO"])
func `classifies gateway media and historical file attachments consistently`(type: String) {
let content = OpenClawChatMessageContent(
type: type,
text: nil,
mimeType: nil,
fileName: nil,
content: nil)
#expect(content.isInlineAttachment)
}
@Test(arguments: ["text", "canvas", "toolCall", "tool_result"])
func `does not classify unrelated content as an attachment`(type: String) {
let content = OpenClawChatMessageContent(
type: type,
text: nil,
mimeType: "image/png",
fileName: "not-an-attachment.png",
content: nil)
#expect(!content.isInlineAttachment)
}
@Test func `does not classify content without a type as an attachment`() {
let content = OpenClawChatMessageContent(
type: nil,
text: nil,
mimeType: "image/png",
fileName: "not-an-attachment.png",
content: nil)
#expect(!content.isInlineAttachment)
}
@Test(arguments: ["user", "assistant"], ["image", "audio", "IMAGE", "AuDiO"])
func `exports canonical media-only messages`(role: String, type: String) {
let isAudio = type.lowercased() == "audio"
let filename = isAudio ? "voice-note.m4a" : "photo.png"
let message = OpenClawChatMessage(
role: role,
content: [
OpenClawChatMessageContent(
type: type,
text: nil,
mimeType: isAudio ? "audio/mp4" : "image/png",
fileName: filename,
content: nil),
],
timestamp: 0)
let markdown = ChatTranscriptExporter.markdown(
sessionTitle: "Media chat",
sessionKey: "agent:main",
messages: [message])
#expect(markdown == """
# Media chat
### \(role == "user" ? "User" : "Assistant") 1970-01-01T00:00:00Z
_[attachment: \(filename)]_
""")
}
@Test(arguments: ["image", "audio", "IMAGE", "AuDiO"])
func `preserves canonical media alongside message text`(type: String) {
let isAudio = type.lowercased() == "audio"
let filename = isAudio ? "voice-note.m4a" : "photo.png"
let message = OpenClawChatMessage(
role: "user",
content: [
OpenClawChatMessageContent(
type: "text",
text: "Here is the attachment.",
mimeType: nil,
fileName: nil,
content: nil),
OpenClawChatMessageContent(
type: type,
text: nil,
mimeType: isAudio ? "audio/mp4" : "image/png",
fileName: filename,
content: nil),
],
timestamp: 0)
let markdown = ChatTranscriptExporter.markdown(
sessionTitle: "Media chat",
sessionKey: "agent:main",
messages: [message])
#expect(markdown == """
# Media chat
### User 1970-01-01T00:00:00Z
Here is the attachment.
_[attachment: \(filename)]_
""")
}
@Test(arguments: ["canvas", "toolCall", "tool_result"])
func `does not export non-attachment content as media`(type: String) {
let message = OpenClawChatMessage(
role: "assistant",
content: [
OpenClawChatMessageContent(
type: type,
text: nil,
mimeType: "image/png",
fileName: "not-an-attachment.png",
content: nil),
],
timestamp: 0)
#expect(ChatTranscriptExporter.markdown(
sessionTitle: "Media chat",
sessionKey: "agent:main",
messages: [message]) == "# Media chat\n")
}
@Test func `sanitizes filename`() {
#expect(
ChatTranscriptExporter.filename(