Skip to content

Commit 83f0527

Browse files
authored
feat(ios): word-paced fade-in for streaming assistant prose (#100884)
1 parent 7cdbfc9 commit 83f0527

7 files changed

Lines changed: 669 additions & 18 deletions

File tree

apps/ios/Tests/SwiftUIRenderSmokeTests.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,30 @@ struct SwiftUIRenderSmokeTests {
140140
}
141141
}
142142

143+
@Test @MainActor func `streaming assistant bubble builds mixed prose and code`() {
144+
let text = """
145+
Earlier prose stays visible.
146+
147+
```swift
148+
let answer = 42
149+
```
150+
151+
Trailing streamed words fade in.
152+
"""
153+
154+
let root = ChatStreamingAssistantBubble(
155+
text: text,
156+
markdownVariant: .standard,
157+
showsAssistantTrace: false,
158+
assistantName: "OpenClaw",
159+
assistantAvatarText: "OC",
160+
assistantAvatarTint: nil,
161+
showsAssistantAvatar: true,
162+
isClean: false)
163+
164+
_ = Self.host(root, size: CGSize(width: 393, height: 400))
165+
}
166+
143167
@Test @MainActor func `root tabs builds device orientation shell matrix`() {
144168
for scenario in Self.rootTabsShellScenarios() {
145169
let appModel = NodeAppModel()

apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift

Lines changed: 182 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,36 +14,62 @@ struct ChatMarkdownRenderer: View {
1414
case assistant
1515
}
1616

17-
let text: String
17+
let snapshot: ChatMarkdownRenderSnapshot
1818
let context: Context
1919
let variant: ChatMarkdownVariant
2020
let font: Font
2121
let textColor: Color
22-
/// False while the message is still streaming: trailing open fences and
23-
/// growing tables then stay on the cheap plain-text path.
24-
var isComplete: Bool = true
22+
var reveal: ChatMarkdownProseReveal?
23+
24+
init(
25+
text: String,
26+
context: Context,
27+
variant: ChatMarkdownVariant,
28+
font: Font,
29+
textColor: Color,
30+
isComplete: Bool = true)
31+
{
32+
self.init(
33+
snapshot: ChatMarkdownRenderSnapshot(text: text, isComplete: isComplete),
34+
context: context,
35+
variant: variant,
36+
font: font,
37+
textColor: textColor)
38+
}
39+
40+
init(
41+
snapshot: ChatMarkdownRenderSnapshot,
42+
context: Context,
43+
variant: ChatMarkdownVariant,
44+
font: Font,
45+
textColor: Color,
46+
reveal: ChatMarkdownProseReveal? = nil)
47+
{
48+
self.snapshot = snapshot
49+
self.context = context
50+
self.variant = variant
51+
self.font = font
52+
self.textColor = textColor
53+
self.reveal = reveal
54+
}
2555

2656
var body: some View {
27-
let processed = ChatMarkdownPreprocessor.preprocess(markdown: self.text)
28-
let blocks = ChatMarkdownBlockSegmenter.segments(
29-
markdown: processed.cleaned,
30-
isComplete: self.isComplete)
3157
VStack(alignment: .leading, spacing: 10) {
32-
ForEach(Array(blocks.enumerated()), id: \.offset) { entry in
33-
self.blockView(entry.element)
58+
ForEach(Array(self.snapshot.blocks.enumerated()), id: \.offset) { entry in
59+
self.blockView(entry.element, index: entry.offset)
3460
}
3561

36-
if !processed.images.isEmpty {
37-
InlineImageList(images: processed.images)
62+
if !self.snapshot.images.isEmpty {
63+
InlineImageList(images: self.snapshot.images)
3864
}
3965
}
4066
}
4167

4268
@ViewBuilder
43-
private func blockView(_ block: ChatMarkdownBlock) -> some View {
69+
private func blockView(_ block: ChatMarkdownRenderedBlock, index: Int) -> some View {
4470
switch block {
45-
case let .prose(markdown):
46-
Text(self.markdownText(ChatMarkdownDisplayPreprocessor.preserveChatSoftBreaks(in: markdown)))
71+
case let .prose(prose):
72+
self.proseText(prose, index: index)
4773
.font(self.font)
4874
.foregroundStyle(self.textColor)
4975
.tint(self.linkColor)
@@ -58,15 +84,154 @@ struct ChatMarkdownRenderer: View {
5884
}
5985
}
6086

87+
private func proseText(_ prose: ChatMarkdownProse, index: Int) -> SwiftUI.Text {
88+
guard let reveal = self.reveal, reveal.blockIndex == index else {
89+
return SwiftUI.Text(prose.attributed)
90+
}
91+
return prose.revealedText(
92+
frame: revealedOpacities(state: reveal.state, now: reveal.now),
93+
textColor: self.textColor)
94+
}
95+
6196
private var linkColor: Color {
6297
self.context == .user ? self.textColor : OpenClawChatTheme.accent
6398
}
99+
}
100+
101+
struct ChatMarkdownProseReveal {
102+
let blockIndex: Int
103+
let state: ChatStreamingRevealState
104+
let now: TimeInterval
105+
}
106+
107+
struct ChatMarkdownRenderSnapshot {
108+
let blocks: [ChatMarkdownRenderedBlock]
109+
let images: [ChatMarkdownPreprocessor.InlineImage]
110+
111+
init(text: String, isComplete: Bool, preparesReveal: Bool = false) {
112+
let processed = ChatMarkdownPreprocessor.preprocess(markdown: text)
113+
self.blocks = ChatMarkdownBlockSegmenter.segments(
114+
markdown: processed.cleaned,
115+
isComplete: isComplete).map { block in
116+
switch block {
117+
case let .prose(markdown):
118+
.prose(ChatMarkdownProse(markdown: markdown, preparesReveal: preparesReveal))
119+
case let .code(code):
120+
.code(code)
121+
case let .math(math):
122+
.math(math)
123+
case let .table(table):
124+
.table(table)
125+
}
126+
}
127+
self.images = processed.images
128+
}
129+
130+
var lastProseIndex: Int? {
131+
self.blocks.lastIndex {
132+
if case .prose = $0 { return true }
133+
return false
134+
}
135+
}
136+
}
137+
138+
enum ChatMarkdownRenderedBlock {
139+
case prose(ChatMarkdownProse)
140+
case code(ChatCodeBlock)
141+
case math(ChatMathBlock)
142+
case table(ChatMarkdownTable)
143+
}
144+
145+
struct ChatMarkdownProse {
146+
struct TailPiece {
147+
let attributed: AttributedString
148+
let wordRange: Range<Int>?
149+
}
64150

65-
private func markdownText(_ markdown: String) -> AttributedString {
151+
let attributed: AttributedString
152+
let plainText: String
153+
let prefix: AttributedString
154+
let tail: [TailPiece]
155+
156+
init(markdown: String, preparesReveal: Bool) {
157+
let displayMarkdown = ChatMarkdownDisplayPreprocessor.preserveChatSoftBreaks(in: markdown)
66158
let options = AttributedString.MarkdownParsingOptions(
67159
interpretedSyntax: .full,
68160
failurePolicy: .returnPartiallyParsedIfPossible)
69-
return (try? AttributedString(markdown: markdown, options: options)) ?? AttributedString(markdown)
161+
let attributed = (try? AttributedString(markdown: displayMarkdown, options: options))
162+
?? AttributedString(displayMarkdown)
163+
let plainText = preparesReveal ? String(attributed.characters) : ""
164+
let wordRanges = preparesReveal
165+
? Array(chatStreamingWordRanges(in: plainText).suffix(24))
166+
: []
167+
let tailStart = wordRanges.first?.lowerBound ?? plainText.count
168+
169+
self.attributed = attributed
170+
self.plainText = plainText
171+
if preparesReveal {
172+
self.prefix = Self.slice(attributed, characterRange: 0..<tailStart)
173+
self.tail = Self.tailPieces(
174+
attributed: attributed,
175+
textLength: plainText.count,
176+
wordRanges: wordRanges,
177+
tailStart: tailStart)
178+
} else {
179+
self.prefix = AttributedString()
180+
self.tail = []
181+
}
182+
}
183+
184+
func revealedText(frame: ChatStreamingRevealFrame, textColor: Color) -> SwiftUI.Text {
185+
self.tail.reduce(SwiftUI.Text(self.prefix)) { text, piece in
186+
var attributed = piece.attributed
187+
if let wordRange = piece.wordRange,
188+
let fading = frame.fading.first(where: { $0.characterRange == wordRange })
189+
{
190+
attributed.foregroundColor = textColor.opacity(fading.opacity)
191+
}
192+
return text + SwiftUI.Text(attributed)
193+
}
194+
}
195+
196+
private static func tailPieces(
197+
attributed: AttributedString,
198+
textLength: Int,
199+
wordRanges: [Range<Int>],
200+
tailStart: Int) -> [TailPiece]
201+
{
202+
guard !wordRanges.isEmpty else { return [] }
203+
var pieces: [TailPiece] = []
204+
var cursor = tailStart
205+
for wordRange in wordRanges {
206+
if cursor < wordRange.lowerBound {
207+
pieces.append(TailPiece(
208+
attributed: self.slice(attributed, characterRange: cursor..<wordRange.lowerBound),
209+
wordRange: nil))
210+
}
211+
pieces.append(TailPiece(
212+
attributed: self.slice(attributed, characterRange: wordRange),
213+
wordRange: wordRange))
214+
cursor = wordRange.upperBound
215+
}
216+
if cursor < textLength {
217+
pieces.append(TailPiece(
218+
attributed: self.slice(attributed, characterRange: cursor..<textLength),
219+
wordRange: nil))
220+
}
221+
return pieces
222+
}
223+
224+
private static func slice(
225+
_ attributed: AttributedString,
226+
characterRange: Range<Int>) -> AttributedString
227+
{
228+
let lower = attributed.characters.index(
229+
attributed.startIndex,
230+
offsetBy: characterRange.lowerBound)
231+
let upper = attributed.characters.index(
232+
attributed.startIndex,
233+
offsetBy: characterRange.upperBound)
234+
return AttributedString(attributed[lower..<upper])
70235
}
71236
}
72237

0 commit comments

Comments
 (0)