Skip to content

fix(ios): use plain Text during streaming to eliminate markdown flicker [AI-assisted]#40985

Closed
eulicesl wants to merge 1 commit into
openclaw:mainfrom
eulicesl:fix/ios-streaming-flicker-clean
Closed

fix(ios): use plain Text during streaming to eliminate markdown flicker [AI-assisted]#40985
eulicesl wants to merge 1 commit into
openclaw:mainfrom
eulicesl:fix/ios-streaming-flicker-clean

Conversation

@eulicesl

@eulicesl eulicesl commented Mar 9, 2026

Copy link
Copy Markdown
Contributor

Problem

ChatStreamingAssistantBubble was calling ChatAssistantTextBody on every token update. ChatAssistantTextBody uses Textual's StructuredText, which re-parses the full markdown AST and re-lays out the entire bubble each time. On fast streams this causes visible layout thrash and flicker.

Fix

Replace the streaming view with a plain SwiftUI Text view. Stable during streaming. Once complete, the message commits and ChatMessageBody re-renders via ChatAssistantTextBody with full markdown — same as before.

Matches the pattern used by ChatGPT and Claude.ai mobile.

Before / After

Before After
StructuredText re-parses full markdown AST on every token → layout thrash / flicker Plain Text during stream → stable bubble → snaps to formatted markdown on commit

Screenshots: This is a streaming animation issue — flicker only manifests during live token delivery, not capturable in a static screenshot. Verify by running the app and triggering any streaming response.

Changes

  • ChatStreamingAssistantBubble: replace ChatAssistantTextBody with plain Text + visibleText computed property
  • visibleText uses includeThinking: false unconditionally — thinking blocks are never shown mid-stream; showsAssistantTrace applies only to the final committed render via ChatAssistantTextBody
  • Removed dead markdownVariant property from ChatStreamingAssistantBubble and its call site in ChatView
  • No new dependencies

Testing

  • swift build on OpenClawKit: clean (0 errors, 0 warnings)
  • Single commit, cherry-picked onto latest upstream/main
  • Degree: lightly tested (build verified; live streaming test requires running gateway)

Bot Review

Greptile flagged two issues — both addressed in this commit:

  1. showsAssistantTrace logic contradiction — fixed: includeThinking: false is now unconditional. Thinking blocks are never displayed during streaming regardless of the flag.
  2. Unused markdownVariant property — removed from struct and call site.

AI Disclosure 🤖

Built with Claude Sonnet 4.6 via OpenClaw. Approach reviewed and understood by human author. Bot feedback addressed before requesting review.

Copilot AI review requested due to automatic review settings March 9, 2026 12:43
@greptile-apps

greptile-apps Bot commented Mar 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces ChatAssistantTextBody (which uses StructuredText to fully re-parse the markdown AST on every streamed token) with a plain SwiftUI Text view inside ChatStreamingAssistantBubble, eliminating the visible layout thrash/flicker during fast streams. The fix is architecturally sound and aligns with the pattern used by major AI chat clients.

Key findings:

  • Logic bug — showsAssistantTrace is effectively ignored in visibleText: AssistantTextParser.segments is called with includeThinking: self.showsAssistantTrace, but the returned array is immediately passed through .filter { $0.kind != .thinking }, which unconditionally removes all thinking segments regardless of the flag value. Thinking blocks are therefore always suppressed during streaming, even when showsAssistantTrace is true. Either the includeThinking argument should be dropped (hard-coded to false, or use the existing visibleSegments helper) or the redundant filter must be removed, depending on the intended behaviour.
  • Unused stored property: markdownVariant is declared on ChatStreamingAssistantBubble but is never read after this change. It should be removed along with its corresponding call-site arguments.

Confidence Score: 3/5

  • Safe to merge after fixing the showsAssistantTrace logic contradiction and removing the dead markdownVariant property.
  • The core approach (switching to plain Text during streaming) is correct and well-motivated. However there is a clear logic bug where the showsAssistantTrace flag is passed as includeThinking to the parser but is then immediately negated by an unconditional filter, making the flag's value meaningless. This could silently hide thinking-block content for users who have the trace view enabled, which is a behavioural regression relative to what the code implies it will do.
  • apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift — specifically the visibleText computed property and the unused markdownVariant property in ChatStreamingAssistantBubble.

Comments Outside Diff (1)

  1. apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift, line 518 (link)

    Unused stored property markdownVariant

    markdownVariant was required when ChatAssistantTextBody was called, but after this change it is no longer referenced anywhere in ChatStreamingAssistantBubble. It is now dead code. Removing it will also require call-sites to stop passing the value, which makes the public interface cleaner.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift
    Line: 518
    
    Comment:
    **Unused stored property `markdownVariant`**
    
    `markdownVariant` was required when `ChatAssistantTextBody` was called, but after this change it is no longer referenced anywhere in `ChatStreamingAssistantBubble`. It is now dead code. Removing it will also require call-sites to stop passing the value, which makes the public interface cleaner.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: f58168c

Comment on lines +539 to +543
let segments = AssistantTextParser.segments(from: self.text, includeThinking: self.showsAssistantTrace)
return segments
.filter { $0.kind != .thinking }
.map(\.text)
.joined(separator: "\n\n")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

showsAssistantTrace parameter is always negated by the downstream filter

AssistantTextParser.segments(from:includeThinking:) is called with includeThinking: self.showsAssistantTrace. When showsAssistantTrace is true the function returns all segments — including .thinking ones. But the immediately-following .filter { $0.kind != .thinking } then unconditionally strips every .thinking segment regardless of that flag.

The net effect is that thinking-block content is always hidden during streaming, even when showsAssistantTrace is true. The includeThinking argument is therefore dead code and the PR description's claim — "correctly excluded from streaming view when showsAssistantTrace is false" — is misleading, because exclusion happens unconditionally.

If the intent is to always suppress thinking during streaming, simplify to:

Suggested change
let segments = AssistantTextParser.segments(from: self.text, includeThinking: self.showsAssistantTrace)
return segments
.filter { $0.kind != .thinking }
.map(\.text)
.joined(separator: "\n\n")
let segments = AssistantTextParser.visibleSegments(from: self.text)
return segments
.map(\.text)
.joined(separator: "\n\n")

If the intent is to honour showsAssistantTrace (show thinking when the flag is true), remove the redundant filter instead:

let segments = AssistantTextParser.segments(from: self.text, includeThinking: self.showsAssistantTrace)
return segments
    .map(\.text)
    .joined(separator: "\n\n")
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift
Line: 539-543

Comment:
**`showsAssistantTrace` parameter is always negated by the downstream filter**

`AssistantTextParser.segments(from:includeThinking:)` is called with `includeThinking: self.showsAssistantTrace`. When `showsAssistantTrace` is `true` the function returns **all** segments — including `.thinking` ones. But the immediately-following `.filter { $0.kind != .thinking }` then unconditionally strips every `.thinking` segment regardless of that flag.

The net effect is that thinking-block content is **always** hidden during streaming, even when `showsAssistantTrace` is `true`. The `includeThinking` argument is therefore dead code and the PR description's claim — "correctly excluded from streaming view when `showsAssistantTrace` is **false**" — is misleading, because exclusion happens unconditionally.

If the intent is to **always** suppress thinking during streaming, simplify to:

```suggestion
        let segments = AssistantTextParser.visibleSegments(from: self.text)
        return segments
            .map(\.text)
            .joined(separator: "\n\n")
```

If the intent is to **honour** `showsAssistantTrace` (show thinking when the flag is `true`), remove the redundant filter instead:

```swift
let segments = AssistantTextParser.segments(from: self.text, includeThinking: self.showsAssistantTrace)
return segments
    .map(\.text)
    .joined(separator: "\n\n")
```

How can I resolve this? If you propose a fix, please make it concise.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f58168c3dd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// Show only the visible (non-thinking) portion during streaming.
let segments = AssistantTextParser.segments(from: self.text, includeThinking: self.showsAssistantTrace)
return segments
.filter { $0.kind != .thinking }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve thinking segments when trace mode is enabled

When showsAssistantTrace is true, this new filtering drops every .thinking segment from the streaming bubble, even though the caller still decides to render the bubble based on AssistantTextParser.hasVisibleContent(..., includeThinking: true) in ChatView. In trace mode, a response that is currently only inside <think> blocks will now render an empty bubble (and never show streaming trace text), which is a regression from the previous ChatAssistantTextBody(includesThinking:) behavior.

Useful? React with 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates the iOS chat streaming UI to avoid markdown re-parsing/layout thrash during token streaming by rendering streaming assistant text as plain Text, while keeping full markdown rendering for the final committed message.

Changes:

  • Replaces ChatAssistantTextBody with plain SwiftUI Text in ChatStreamingAssistantBubble to prevent streaming flicker.
  • Adds visibleText computed property to derive what should be shown during streaming (currently filtering out thinking segments).

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +538 to +541
// Show only the visible (non-thinking) portion during streaming.
let segments = AssistantTextParser.segments(from: self.text, includeThinking: self.showsAssistantTrace)
return segments
.filter { $0.kind != .thinking }

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

visibleText always filters out .thinking segments. That changes behavior vs the previous ChatAssistantTextBody(... includesThinking: showsAssistantTrace) path: when showsAssistantTrace is true, thinking content will never appear during streaming, and if the stream is thinking-only the bubble can render as an empty Text("") even though ChatView will still show the streaming bubble (because hasVisibleContent(... includeThinking: true) is true). Adjust the logic so thinking segments are included when showsAssistantTrace is true (and use response-only segments when it’s false).

Suggested change
// Show only the visible (non-thinking) portion during streaming.
let segments = AssistantTextParser.segments(from: self.text, includeThinking: self.showsAssistantTrace)
return segments
.filter { $0.kind != .thinking }
let segments = AssistantTextParser.segments(from: self.text, includeThinking: self.showsAssistantTrace)
let visibleSegments: [AssistantTextParser.Segment] = {
// When showsAssistantTrace is true, include thinking segments so the trace
// is visible during streaming. Otherwise, show only response segments.
if self.showsAssistantTrace {
return segments
} else {
return segments.filter { $0.kind != .thinking }
}
}()
return visibleSegments

Copilot uses AI. Check for mistakes.
}
.padding(12)
.assistantBubbleContainerStyle()
}

/// Strip markdown symbols during streaming so the plain Text view looks reasonable.

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment for visibleText says it "Strip[s] markdown symbols during streaming", but this property only removes thinking segments (and the PR description notes markdown symbols remain visible during streaming). Please update the comment to reflect the actual behavior to avoid misleading future changes.

Suggested change
/// Strip markdown symbols during streaming so the plain Text view looks reasonable.
/// Show only non-thinking segments during streaming so the plain Text view reflects visible content.

Copilot uses AI. Check for mistakes.
@eulicesl eulicesl force-pushed the fix/ios-streaming-flicker-clean branch from f58168c to 177727b Compare March 9, 2026 12:49
@eulicesl eulicesl changed the title fix(ios): use plain Text during streaming to eliminate markdown flicker fix(ios): use plain Text during streaming to eliminate markdown flicker [AI-assisted] Mar 9, 2026
@eulicesl

eulicesl commented Mar 9, 2026

Copy link
Copy Markdown
Contributor Author

@greptile-apps Both issues addressed in amended commit 177727b:

  1. showsAssistantTrace logic contradiction — fixed by hardcoding includeThinking: false in visibleText. Thinking blocks are never shown during streaming; showsAssistantTrace applies only to the final committed render in ChatAssistantTextBody.

  2. Unused markdownVariant property — removed from ChatStreamingAssistantBubble and its call site in ChatView.swift.

@greptile-apps

greptile-apps Bot commented Mar 9, 2026

Copy link
Copy Markdown
Contributor

Perfect! Both fixes confirmed:

  1. showsAssistantTrace contradiction: visibleText now hardcodes includeThinking: false. Clear comment explains thinking blocks only appear in the final committed render via ChatAssistantTextBody. ✅

  2. Unused markdownVariant: Removed from struct and call site in ChatView.swift:222. ✅

The logic is cleaner and matches the documented behavior. Ready for final review.

@eulicesl eulicesl force-pushed the fix/ios-streaming-flicker-clean branch from 177727b to bef8bad Compare March 16, 2026 17:36

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bef8badb25

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

/// Thinking blocks are never shown during streaming regardless of showsAssistantTrace —
/// they appear only in the final committed message via ChatAssistantTextBody.
private var visibleText: String {
AssistantTextParser.segments(from: self.text, includeThinking: false)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Align streaming visibility with trace-filtered content

This unconditionally removes thinking segments during streaming, but OpenClawChatView still decides whether to render the streaming bubble using AssistantTextParser.hasVisibleContent(..., includeThinking: self.showsAssistantTrace) in ChatView.swift (line 218). In trace mode, a thinking-only stream (e.g. partial <think>...</think> before final text arrives) now passes the visibility check but renders Text(""), producing an empty assistant bubble; this is a regression introduced by this change unless the visibility predicate is updated to match the filtered content.

Useful? React with 👍 / 👎.

@eulicesl eulicesl force-pushed the fix/ios-streaming-flicker-clean branch from bef8bad to 5c363ca Compare March 17, 2026 18:02
ChatStreamingAssistantBubble was calling ChatAssistantTextBody, which
runs StructuredText (Textual) on every token. Textual re-parses the full
markdown AST and re-lays out the entire bubble on each update, causing
visible layout thrash and flicker during streaming.

Replace with a plain SwiftUI Text view while streaming is in progress.
Markdown symbols are visible but the bubble is stable. Once streaming
completes the message is committed and re-rendered via ChatAssistantTextBody
with full markdown formatting, matching the established ChatGPT / Claude.ai
mobile pattern.

No new dependencies. The Textual import in ChatMessageViews is still
required by ChatAssistantTextBody for completed messages.

Fixes: streaming flicker on iOS (and macOS via OpenClawKit)
@eulicesl eulicesl force-pushed the fix/ios-streaming-flicker-clean branch from 5c363ca to cb0d97c Compare March 17, 2026 18:03

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cb0d97cdf1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

/// Thinking blocks are never shown during streaming regardless of showsAssistantTrace —
/// they appear only in the final committed message via ChatAssistantTextBody.
private var visibleText: String {
AssistantTextParser.segments(from: self.text, includeThinking: false)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate streaming bubble on non-thinking content

When trace mode is enabled, this new visibleText path always strips thinking segments, but OpenClawChatView still decides to render the streaming bubble using hasVisibleContent(..., includeThinking: self.showsAssistantTrace). Fresh evidence in this commit is that includeThinking is now hard-coded to false here, so a thinking-only in-flight response will pass the ChatView visibility check and render an empty assistant bubble until non-thinking text arrives.

Useful? React with 👍 / 👎.

@eulicesl

Copy link
Copy Markdown
Contributor Author

Closing this as superseded by #50483.

Why:

  • this older branch addressed one symptom (plain Text during streaming) but accumulated six review threads around trace/thinking visibility drift
  • the replacement branch carries the broader, current fix direction: session scoping, hardened stream acceptance / initial load, and layout stabilization with parser/test coverage
  • the replacement PR is rebased onto current main and build-validated

Canonical replacement: #50483

@eulicesl eulicesl closed this Mar 19, 2026
@eulicesl eulicesl deleted the fix/ios-streaming-flicker-clean branch March 19, 2026 18:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants