Skip to content

Keep a streaming reply as plain text until it is final (1.2.2) - #8

Merged
jkyberneees merged 2 commits into
mainfrom
fix/streaming-ui
Nov 1, 2026
Merged

jkyberneees merged 2 commits into
mainfrom
fix/streaming-ui

Conversation

@jkyberneees

@jkyberneees jkyberneees commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes the chat window freezing in the middle of a long reply — with any model — then showing the rest in one burst, and the transcript going blank when the reply finished.

Diagnosis (the running app sampled during the freeze on an M2 Pro): the main thread was 100% busy in SwiftUI/AppKit layout, hover hit-testing and redraw while the inference queue was idle — the engine (6–7 ms per token through mlxr serve, no pauses) had already finished the reply into the stream buffer. Five causes, all in the chat view, each confirmed by re-sampling after the change:

  1. Every paragraph of the streaming reply was a selectable, AppKit-backed text view that SwiftUI re-created and hit-tested on each update → plain text while streaming, selectable when final; no hover tracking on rows while streaming.
  2. The live-stats dot, "Thinking…" shimmer and typing dots were repeat-forever animations: 60 frames/s, each with a full AppKit layout + hit-test pass → stepped indicators.
  3. Every token mutated conversations, re-rendering the sidebar, the inspector (AppKit sliders) and the whole chat → the reply streams into live state that only the in-progress row reads; written to the conversation once at the end.
  4. The whole reply re-rendered per update → finished paragraphs keep their view, only the paragraph being written re-renders.
  5. Updates paced by their measured main-thread cost (≤ 20/s, never more often than twice what the previous one took).

Result: main thread 52–60% idle during decode of a long reply (was 0%). The blank transcript: a LazyVStack could be left scrolled past stale content when the final layout replaced the streaming one → plain VStack.

Test plan

  • swift test (engine suite; changes are view code)
  • Eight builds sampled with sample during 1,000-word replies; final numbers above
  • User re-test on the final build: streaming feel and transcript after finishing

🤖 Generated with Claude Code

Text selection makes every paragraph of a message an AppKit-backed view
that SwiftUI re-creates and hit-tests (hover tracking) on each update.
For a long reply streamed 30 times a second that saturated the main
thread: the window froze for seconds while the engine finished the reply
into the stream buffer, then everything appeared at once. Sampled on an
M2 Pro: 100% of the main thread in the SwiftUI graph update, a third of
it in NSHostingView.didRequestHoverUpdate, the engine idle.

The streaming message (and the thinking block while in progress) is
plain SwiftUI text and becomes selectable when final; the UI flushes at
20 updates a second.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Failed ❌

View logs ↗
8ad1eca 2026-11-01T20:35:21.387Z View logs ↗
  • Build: Failed ❌

View logs ↗
990cf24 2026-11-01T19:41:16.429Z View logs ↗

…stack

Sampled on an M2 Pro with the 1.2.1 app, the main thread was saturated
during a long reply (SwiftUI/AppKit layout, hover hit-testing, redraw)
while the engine had long finished; afterwards the transcript could go
blank. Changes, each measured with `sample` during a 1,000-word reply:

- the reply streams into live state read only by the in-progress row;
  the conversation store is written once at the end (every token had
  re-rendered the sidebar, the inspector's AppKit sliders and the chat)
- finished paragraphs keep their view; only the one being written
  re-renders; no hover tracking on rows while streaming
- live-stats dot, "Thinking…" and typing dots are stepped, not
  repeat-forever animations (those rendered every frame with a full
  AppKit layout and hit-test pass each)
- updates are paced by their measured main-thread cost (<= 20/s, never
  more often than twice the previous update's cost): main thread 52-60%
  idle during decode, from 0%
- plain VStack for the message list (a LazyVStack could be left scrolled
  past stale content when the final layout replaced the streaming one)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jkyberneees
jkyberneees merged commit 73c1365 into main Nov 1, 2026
4 of 5 checks passed
@jkyberneees
jkyberneees deleted the fix/streaming-ui branch November 1, 2026 20:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant