From 7c34883267bd509ff647f0ffd5443d5fb5187644 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 16 Jan 2026 09:16:39 +0000 Subject: [PATCH] refactor: consolidate chat markdown rendering --- apps/ios/SwiftSources.input.xcfilelist | 1 + .../ClawdbotChatUI/ChatMarkdownRenderer.swift | 85 +++++++++++++++++++ .../ClawdbotChatUI/ChatMessageViews.swift | 75 +++++----------- .../Sources/ClawdbotChatUI/ChatView.swift | 9 +- .../ChatMarkdownPreprocessorTests.swift | 20 +++++ 5 files changed, 136 insertions(+), 54 deletions(-) create mode 100644 apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift create mode 100644 apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ChatMarkdownPreprocessorTests.swift diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist index 2a267bb0e..0598b0e4e 100644 --- a/apps/ios/SwiftSources.input.xcfilelist +++ b/apps/ios/SwiftSources.input.xcfilelist @@ -26,6 +26,7 @@ Sources/Voice/VoiceTab.swift Sources/Voice/VoiceWakeManager.swift Sources/Voice/VoiceWakePreferences.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift +../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift new file mode 100644 index 000000000..3024c4082 --- /dev/null +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift @@ -0,0 +1,85 @@ +import SwiftUI +import Textual + +public enum ChatMarkdownVariant: String, CaseIterable, Sendable { + case standard + case compact +} + +@MainActor +struct ChatMarkdownRenderer: View { + enum Context { + case user + case assistant + } + + let text: String + let context: Context + let variant: ChatMarkdownVariant + let font: Font + let textColor: Color + + var body: some View { + let processed = ChatMarkdownPreprocessor.preprocess(markdown: self.text) + VStack(alignment: .leading, spacing: 10) { + StructuredText(markdown: processed.cleaned) + .modifier(ChatMarkdownStyle( + variant: self.variant, + context: self.context, + font: self.font, + textColor: self.textColor)) + + if !processed.images.isEmpty { + InlineImageList(images: processed.images) + } + } + } +} + +private struct ChatMarkdownStyle: ViewModifier { + let variant: ChatMarkdownVariant + let context: ChatMarkdownRenderer.Context + let font: Font + let textColor: Color + + func body(content: Content) -> some View { + content + .font(self.font) + .foregroundStyle(self.textColor) + .textual.structuredTextStyle(self.variant == .compact ? .default : .gitHub) + .textual.inlineStyle(self.inlineStyle) + .textual.textSelection(.enabled) + } + + private var inlineStyle: InlineStyle { + let linkColor: Color = self.context == .user ? self.textColor : .accentColor + let codeScale: CGFloat = self.variant == .compact ? 0.85 : 0.9 + return InlineStyle() + .code(.monospaced, .fontScale(codeScale)) + .link(.foregroundColor(linkColor)) + } +} + +@MainActor +private struct InlineImageList: View { + let images: [ChatMarkdownPreprocessor.InlineImage] + + var body: some View { + ForEach(images, id: \.id) { item in + if let img = item.image { + ClawdbotPlatformImageFactory.image(img) + .resizable() + .scaledToFit() + .frame(maxHeight: 260) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.white.opacity(0.12), lineWidth: 1)) + } else { + Text(item.label.isEmpty ? "Image" : item.label) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } +} diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift index bbbfda565..2b1bacc08 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift @@ -1,7 +1,6 @@ import ClawdbotKit import Foundation import SwiftUI -import Textual private enum ChatUIConstants { static let bubbleMaxWidth: CGFloat = 560 @@ -138,10 +137,16 @@ private struct ChatBubbleShape: InsettableShape { struct ChatMessageBubble: View { let message: ClawdbotChatMessage let style: ClawdbotChatView.Style + let markdownVariant: ChatMarkdownVariant let userAccent: Color? var body: some View { - ChatMessageBody(message: self.message, isUser: self.isUser, style: self.style, userAccent: self.userAccent) + ChatMessageBody( + message: self.message, + isUser: self.isUser, + style: self.style, + markdownVariant: self.markdownVariant, + userAccent: self.userAccent) .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading) .frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading) .padding(.horizontal, 2) @@ -155,6 +160,7 @@ private struct ChatMessageBody: View { let message: ClawdbotChatMessage let isUser: Bool let style: ClawdbotChatView.Style + let markdownVariant: ChatMarkdownVariant let userAccent: Color? var body: some View { @@ -170,9 +176,14 @@ private struct ChatMessageBody: View { isUser: self.isUser) } } else if self.isUser { - ChatMarkdownView(text: text, textColor: textColor, font: .system(size: 14)) + ChatMarkdownRenderer( + text: text, + context: .user, + variant: self.markdownVariant, + font: .system(size: 14), + textColor: textColor) } else { - ChatAssistantTextBody(text: text) + ChatAssistantTextBody(text: text, markdownVariant: self.markdownVariant) } if !self.inlineAttachments.isEmpty { @@ -584,64 +595,22 @@ private struct TypingDots: View { } @MainActor -private struct ChatMarkdownView: View { - let text: String - let textColor: Color - let font: Font - - var body: some View { - let processed = ChatMarkdownPreprocessor.preprocess(markdown: self.text) - VStack(alignment: .leading, spacing: 10) { - StructuredText(markdown: processed.cleaned) - .font(self.font) - .foregroundStyle(self.textColor) - .textual.textSelection(.enabled) - - if !processed.images.isEmpty { - InlineImageList(images: processed.images) - } - } - } -} - @MainActor private struct ChatAssistantTextBody: View { let text: String + let markdownVariant: ChatMarkdownVariant var body: some View { let segments = AssistantTextParser.segments(from: self.text) VStack(alignment: .leading, spacing: 10) { ForEach(segments) { segment in let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14) - ChatMarkdownView(text: segment.text, textColor: ClawdbotChatTheme.assistantText, font: font) - } - } - } -} - -@MainActor -private struct InlineImageList: View { - let images: [ChatMarkdownPreprocessor.InlineImage] - - var body: some View { - if images.isEmpty { - EmptyView() - } else { - ForEach(images, id: \.id) { item in - if let img = item.image { - ClawdbotPlatformImageFactory.image(img) - .resizable() - .scaledToFit() - .frame(maxHeight: 260) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(Color.white.opacity(0.12), lineWidth: 1)) - } else { - Text(item.label.isEmpty ? "Image" : item.label) - .font(.footnote) - .foregroundStyle(.secondary) - } + ChatMarkdownRenderer( + text: segment.text, + context: .assistant, + variant: self.markdownVariant, + font: font, + textColor: ClawdbotChatTheme.assistantText) } } } diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatView.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatView.swift index 537647001..925dd1736 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatView.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatView.swift @@ -14,6 +14,7 @@ public struct ClawdbotChatView: View { @State private var hasPerformedInitialScroll = false private let showsSessionSwitcher: Bool private let style: Style + private let markdownVariant: ChatMarkdownVariant private let userAccent: Color? private enum Layout { @@ -42,11 +43,13 @@ public struct ClawdbotChatView: View { viewModel: ClawdbotChatViewModel, showsSessionSwitcher: Bool = false, style: Style = .standard, + markdownVariant: ChatMarkdownVariant = .standard, userAccent: Color? = nil) { self._viewModel = State(initialValue: viewModel) self.showsSessionSwitcher = showsSessionSwitcher self.style = style + self.markdownVariant = markdownVariant self.userAccent = userAccent } @@ -151,7 +154,11 @@ public struct ClawdbotChatView: View { @ViewBuilder private var messageListRows: some View { ForEach(self.visibleMessages) { msg in - ChatMessageBubble(message: msg, style: self.style, userAccent: self.userAccent) + ChatMessageBubble( + message: msg, + style: self.style, + markdownVariant: self.markdownVariant, + userAccent: self.userAccent) .frame( maxWidth: .infinity, alignment: msg.role.lowercased() == "user" ? .trailing : .leading) diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ChatMarkdownPreprocessorTests.swift b/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ChatMarkdownPreprocessorTests.swift new file mode 100644 index 000000000..399de0e7e --- /dev/null +++ b/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ChatMarkdownPreprocessorTests.swift @@ -0,0 +1,20 @@ +import Testing +@testable import ClawdbotChatUI + +@Suite("ChatMarkdownPreprocessor") +struct ChatMarkdownPreprocessorTests { + @Test func extractsDataURLImages() { + let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////GQAJ+wP/2hN8NwAAAABJRU5ErkJggg==" + let markdown = """ + Hello + + ![Pixel](data:image/png;base64,\(base64)) + """ + + let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown) + + #expect(result.cleaned == "Hello") + #expect(result.images.count == 1) + #expect(result.images.first?.image != nil) + } +}