perf(desktop): keep the app smooth while a chat answer streams - #13053
Conversation
…flush Every 35 ms buffer flush re-parsed and re-laid out the entire accumulated answer on the main thread: OmiMarkdownContent.textView ran the SwiftUI Markdown parse before checking appKitProseSelection and threw the result away; ChatSelectableProseText replaced the whole NSTextStorage and then laid the whole answer out again in a throwaway TextKit stack for sizeThatFits; applySentenceSpacing compiled two regexes per line, preprocessText two more, the tilde scan searched every code span per character, strippingPendingTail copied the answer into an array, and one text block was normalized twice. Now the prose view edits its storage from the first character that differs (characters or attributes), answers its height from its own incremental layout when the container is already at the proposed width (TextKit 1 is forced so live and throwaway heights agree), the AppKit branch skips the SwiftUI prose path, the regexes are compiled once, and the suffix checks inspect only the suffix. ChatStreamingRenderProbe (DEBUG-only) counts the parses, measures and storage edits so a test can pin the work per flush. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…side SwiftUI ChatAssistantAnswerText.visible built the block projection of every block — tool outputs included — and whitespace-normalized it plus the body on every call, and ChatBubble called it five to seven times per body evaluation; the transcript also called it for every mounted row on every body pass through ChatBubbleMetadataBand. Sampled on a real streaming turn it was a quarter of all main-thread samples. The fallback is now derived only on the paths that return it, hasVisible answers emptiness without building the text, and ChatBubble derives its RowText once per body. ChatOmiMark's TimelineView(.animation) was worse: on AppKit every timeline frame is a SwiftUI graph update rendered inside NSHostingView.layout(), so the whole hosting view was laid out, its view tree walked and a Core Animation commit made at display refresh rate for as long as an answer streamed. The mounted transcript spent 39.6 ms of every 35 ms on that with nothing arriving. The animated mark is now an NSView that redraws its own layer on a 60 Hz timer; the resting mark is unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… ahead of its echo Every streaming flush scheduled a journal write of the whole row, and ChatJournalWriteCoordinator queued one task per flush behind the one before it, so whenever a kernel round trip outlasted a flush the queue grew for the rest of the answer and drained only after it settled. Each completed write then re-listed the journal and projectJournalTurns republished the entire transcript with the echo's older text, so the visible answer stepped back a few words on every write and forward again on the next flush. A streaming write now replaces the waiting one instead of queueing behind it (at most one in flight and one waiting, always the newest row); a durable write seals the slot queued before it so it keeps its place in line; cancelAll drops a waiting snapshot. carryingLocalOnlyFields keeps the live row's text and blocks when a streaming echo is behind it, and projectJournalTurns publishes only when a row actually changed. Kernel journal turns remain the durable authority: a terminal or kernel-owned row is never streaming and is taken whole, and the coalesced write still lands before terminalization. Failure-Class: none Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ChatStreamingRenderBudgetTests mounts the production transcript through the gesture harness with tool-call-bearing history, streams a long Markdown answer at the buffer's cadence and asserts, through the DEBUG render probe, that a flush parses once, edits the storage's tail, measures from the live layout, and that the animating mark does not lay the transcript out again. Main-thread CPU per flush is printed, not asserted. ChatStreamingJournalCoalescingTests pins latest-wins coalescing, durable write ordering, cancellation, and that a streaming echo behind the live row neither moves its text back nor republishes. ChatSelectableProseIncrementalEditTests pins the tail edit, attribute-aware prefixes, composed characters, live-vs-throwaway height agreement, and that the hand-rolled preprocessing matches the original regular expressions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…out of ChatProvider ChatProvider.swift is held under the agent-runtime convergence ceiling (7,382 lines); the streaming fixes had pushed it over. The sentence-spacing normalizer now lives in ChatProvider+AssistantTextNormalization.swift and carryingLocalOnlyFields beside its only caller in ChatProvider+JournalProjection.swift. No behaviour change; the file ends smaller than it started. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
6 issues found across 20 files
Confidence score: 3/5
ChatSelectableProse.swiftstill rescans the accumulated answer during every append, leaving the streaming flush path O(answer length) and risking degraded performance on long responses — retain an incremental diff/state approach.ChatSelectableProse.swiftcan reuseliveHeightwhen formatting changes without altering rendered characters, potentially caching the wrong height for the new attributed render — compare full attributed storage or defer the reuse.ChatBubble.swiftderivesvisibleAnswerTextfor every mounted user bubble on each transcript pass even though rendering usesmessage.text, adding avoidable per-row work to the streaming path — remove or narrow that derivation.- The streaming budget test and
chat-first-cohesive.yamlprovide weaker validation than their assertions and coverage claims suggest: coalesced flushes can fail the budget precondition,perFlushSnapshotsis unused, and the happy path does not verify streaming behavior — align the assertions and coverage metadata with behavior actually exercised.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift">
<violation number="1" location="desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift:178">
P2: Every mounted user bubble now performs the full `visibleAnswerText` derivation on each transcript body pass, despite using only `message.text` for rendering. Avoid this extra per-row work on the streaming hot path while preserving the existing trimmed copy payload.</violation>
</file>
<file name="desktop/macos/e2e/flows/chat-first-cohesive.yaml">
<violation number="1" location="desktop/macos/e2e/flows/chat-first-cohesive.yaml:51">
P3: The three new `covers:` entries claim coverage for streaming-pipeline behavior that this happy-path flow only mounts, never verifies. The flow sends one short query ("Acknowledge this attached conversation.") and asserts only are-you-idle plus message-content — it never exercises the per-flush work counters, the journal-write coalescing semantics (newest-first, one-in-flight, terminalization gating, cancelAll), or the sentence-spacing/pending-tail normalization output. Each of those behaviors is asserted hermetically in `ChatStreamingRenderBudgetTests` / `ChatStreamingJournalCoalescingTests` / unit tests, not here. Per the covers discipline (list a source only when the flow exercises *and* verifies its behavior), these entries overstate what the flow covers. Either drop the entries or keep only files whose distinguishing behavior the flow actually asserts; the comment for `ChatStreamingRenderProbe` already concedes it is "asserted hermetically ... not here," which contradicts listing it as covered.</violation>
</file>
<file name="desktop/macos/Desktop/Tests/ChatStreamingRenderBudgetTests.swift">
<violation number="1" location="desktop/macos/Desktop/Tests/ChatStreamingRenderBudgetTests.swift:65">
P3: `perFlushSnapshots` is appended to on every flush but never read after the loop; only `totals` and the idle snapshot are asserted. The whole array (and the `delta` calls that fill it) is dead work in a hot loop that also skews the very CPU-budget measurement this test reports. Drop the array and the per-flush `previous`/`delta` bookkeeping, or assert on it.</violation>
<violation number="2" location="desktop/macos/Desktop/Tests/ChatStreamingRenderBudgetTests.swift:123">
P2: The precondition `appKitProseBuild >= flushes` requires zero coalescing across ~100 flushes, but the same test's own comment on the storage counters says "two flushes landing in one run-loop turn are one edit, which is coalescing" and therefore allows up to 10% coalescing for `storageIncrementalEdit`. If a single pair of flushes coalesces into one AppKit prose build on a loaded CI runner, this hard `>= flushes` precondition fails the whole budget test. Give the prose-build precondition the same slack as the incremental-edit assertion (e.g. `>= flushes * 9 / 10`) or settle the inconsistency.</violation>
</file>
<file name="desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableProse.swift">
<violation number="1" location="desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableProse.swift:241">
P2: Each append still scans the accumulated answer before editing it. `commonPrefix` and the attribute walk traverse the unchanged prefix on every flush, so this hot path remains O(answer length); retain incremental diff state or a validated append cursor, with a fallback when earlier Markdown can be restyled.</violation>
<violation number="2" location="desktop/macos/Desktop/Sources/MainWindow/Components/ChatSelectableProse.swift:304">
P2: When formatting changes without changing rendered characters, `liveHeight` can reuse the old layout and cache its height for the new render entry. Compare the full attributed storage before using liveHeight, or defer measurement until `updateNSView` applies the new attributes.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| # Streaming answers on this surface exercise the incremental prose edit and | ||
| # the coalesced journal write; the work-per-flush counters are DEBUG-only | ||
| # and asserted hermetically in ChatStreamingRenderBudgetTests. | ||
| - desktop/macos/Desktop/Sources/MainWindow/Components/ChatStreamingRenderProbe.swift |
There was a problem hiding this comment.
P3: The three new covers: entries claim coverage for streaming-pipeline behavior that this happy-path flow only mounts, never verifies. The flow sends one short query ("Acknowledge this attached conversation.") and asserts only are-you-idle plus message-content — it never exercises the per-flush work counters, the journal-write coalescing semantics (newest-first, one-in-flight, terminalization gating, cancelAll), or the sentence-spacing/pending-tail normalization output. Each of those behaviors is asserted hermetically in ChatStreamingRenderBudgetTests / ChatStreamingJournalCoalescingTests / unit tests, not here. Per the covers discipline (list a source only when the flow exercises and verifies its behavior), these entries overstate what the flow covers. Either drop the entries or keep only files whose distinguishing behavior the flow actually asserts; the comment for ChatStreamingRenderProbe already concedes it is "asserted hermetically ... not here," which contradicts listing it as covered.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At desktop/macos/e2e/flows/chat-first-cohesive.yaml, line 51:
<comment>The three new `covers:` entries claim coverage for streaming-pipeline behavior that this happy-path flow only mounts, never verifies. The flow sends one short query ("Acknowledge this attached conversation.") and asserts only are-you-idle plus message-content — it never exercises the per-flush work counters, the journal-write coalescing semantics (newest-first, one-in-flight, terminalization gating, cancelAll), or the sentence-spacing/pending-tail normalization output. Each of those behaviors is asserted hermetically in `ChatStreamingRenderBudgetTests` / `ChatStreamingJournalCoalescingTests` / unit tests, not here. Per the covers discipline (list a source only when the flow exercises *and* verifies its behavior), these entries overstate what the flow covers. Either drop the entries or keep only files whose distinguishing behavior the flow actually asserts; the comment for `ChatStreamingRenderProbe` already concedes it is "asserted hermetically ... not here," which contradicts listing it as covered.</comment>
<file context>
@@ -45,6 +45,12 @@ covers:
+ # Streaming answers on this surface exercise the incremental prose edit and
+ # the coalesced journal write; the work-per-flush counters are DEBUG-only
+ # and asserted hermetically in ChatStreamingRenderBudgetTests.
+ - desktop/macos/Desktop/Sources/MainWindow/Components/ChatStreamingRenderProbe.swift
+ - desktop/macos/Desktop/Sources/Chat/ChatTurnLifecycle.swift
+ - desktop/macos/Desktop/Sources/Providers/ChatProvider+AssistantTextNormalization.swift
</file context>
| let cpuAfter = Self.mainThreadCPUNanoseconds() | ||
| flushCPUMilliseconds.append(Double(cpuAfter - cpuBefore) / 1_000_000) | ||
| let now = ChatStreamingRenderProbe.snapshot() | ||
| perFlushSnapshots.append(Self.delta(from: previous, to: now)) |
There was a problem hiding this comment.
P3: perFlushSnapshots is appended to on every flush but never read after the loop; only totals and the idle snapshot are asserted. The whole array (and the delta calls that fill it) is dead work in a hot loop that also skews the very CPU-budget measurement this test reports. Drop the array and the per-flush previous/delta bookkeeping, or assert on it.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At desktop/macos/Desktop/Tests/ChatStreamingRenderBudgetTests.swift, line 65:
<comment>`perFlushSnapshots` is appended to on every flush but never read after the loop; only `totals` and the idle snapshot are asserted. The whole array (and the `delta` calls that fill it) is dead work in a hot loop that also skews the very CPU-budget measurement this test reports. Drop the array and the per-flush `previous`/`delta` bookkeeping, or assert on it.</comment>
<file context>
@@ -0,0 +1,258 @@
+ let cpuAfter = Self.mainThreadCPUNanoseconds()
+ flushCPUMilliseconds.append(Double(cpuAfter - cpuBefore) / 1_000_000)
+ let now = ChatStreamingRenderProbe.snapshot()
+ perFlushSnapshots.append(Self.delta(from: previous, to: now))
+ previous = now
+ }
</file context>
The 'Desktop Swift Static & Test Contracts' lane failed on ChatStreamingRenderBudgetTests with markFrames=0: the working mark never animated because the CI host ships with accessibility Reduce Motion on, so ChatOmiMark took its static branch and the mark-frame precondition found no frames at all. The gate job then hard-failed in 3s on VERIFY_RESULT, as it must. The harness gains an opt-in environment pin, and the budget test pins Reduce Motion off: the mark's frames are the very thing the idle budget measures, so the contract must not depend on the host's accessibility settings. The report line also prints the host's Reduce Motion value and the total mark-frame count, so the next red run diagnoses itself.
Cubic triage on PR #13053 — valid findings fixed, each with a regression test where one could pin it: - ChatOmiMark: resolve the animated frame's CG fill through the glass's pinned appearance (Ink.nsPrimaryOnGlass) so dark-Aqua windows stop drawing near-white dots on the light transcript panel, and guard deinit's timer teardown with the main-thread check an off-main dealloc needs. - ChatProvider+AssistantTextNormalization: replace the per-line backtick parity split with one delimiter-aware scan sharing the renderer's OmiMarkdownInlineCode semantics — whole backtick runs (double-backtick spans), spans crossing a line break, and a fence that closes only on a bare run of its own character at least as long as its opener. - ChatProvider+JournalProjection: run the citation projection before the publication gate so a no-op refresh still binds a restored follow-up's inherited chips, and merge replayed metadata field-by-field so a journal echo carrying modelsUsed no longer erases the in-memory completion evidence. - ChatBubble: derive the block-walking answer only for rows with blocks, and run the O(lines) truncation scan once per body pass instead of three times. - ChatSelectableProse: check composed-character boundaries in both strings before the storage edit, so a reparse that drops a combining mark cannot start the edit inside the old storage's sequence. - ChatStreamingRenderBudgetTests: drop the per-flush snapshot bookkeeping that nothing read (it skewed the CPU it reports — measured total fell from ~2.7s to ~1.4s) and give the prose-build precondition the same coalescing slack as the storage contract. - ChatStreamingRenderProbe: remove the never-called count(_:). - Changelog: keep the user-visible outcome only.
CI's Xcode 16.4 job (Apple Swift 6.1.2) evaluated #if compiler(>=6.2) as false, so the harness took the #else branch and fed the public \.accessibilityReduceMotion key path to environment(_:_) — read-only in that SDK too, so the module failed to compile (ChatTranscriptGesture- HarnessTests.swift:1063). That one error killed both the swift-test job and the launcher script lane's sentinel check, whose test-feature-sentinel-negative-control.sh runs swift build --build-tests over the same package (it passed on the previous head, so it was fallout, not a flake). Use the underscored WritableKeyPath unconditionally: it is get+set with @available(macOS 10.15, *) in the SDK interface, projects with a macOS 14 deployment floor (ProtonVPN Home) already use it unguarded, and it was verified to propagate to readers of the public key path. One code path, no toolchain conditional, nothing left to mispredict.
Summary
While an answer streams in the main chat, the whole app turns slow and stuttery; the moment the turn settles it is smooth again. I measured it first, then removed every per-flush cost that scaled with the answer instead of with the flush.
The streaming buffer flushes every 35 ms. Before this change each flush did all of the following on the main thread, over the whole accumulated answer:
OmiMarkdownContent.textViewraninlineCopyContent/styledAttributedStringbefore checkingappKitProseSelection, so the live transcript (which always draws throughChatSelectableProse) paid a SwiftUI parse it never rendered.NSTextStorage.setAttributedStringon the liveNSTextView, and a throwaway TextKit stack laying the whole answer out again insizeThatFits(the render cache misses on every flush, since the key is the full text).NSRegularExpressioncompiles per flush:applySentenceSpacingcompiled two patterns per line,preprocessTexttwo more per line, and the answer was normalized twice (message text and its text block), plus anArray(text)copy of the answer instrippingPendingTailand a quadratic tilde scan (first(where:)over every code span for every character).ChatAssistantAnswerText.visibleunconditionally built the block projection of every block — tool outputs included — and whitespace-normalized it plus the body, andChatBubblere-derived it at each site that read it; the transcript also derived it for every mounted row on every body pass throughChatBubbleMetadataBand. Sampled on a real streaming turn this was the single largest main-thread cost (25% of samples).ChatJournalWriteCoordinatorchained one task per flush, so whenever a kernel round trip took longer than 35 ms the queue grew for the rest of the answer and drained only after it settled (exactly the "smooth once the response is done" signature). Each completed write then re-listed the journal andprojectJournalTurnsrepublished the entire transcript with the echo's older text, so the visible answer stepped back a few words and forward again on the next flush.What changed
ChatSelectableProseTextedits the live text storage from the first character that differs (in characters or attributes), and answerssizeThatFitsfrom the live view's own incremental layout when the container is already at the proposed width; the throwaway measurement remains the fallback for any other width. The view is created as TextKit 1 explicitly so the live and throwaway heights are the same number (asserted).OmiMarkdownContent.textViewno longer runs the SwiftUI prose path for the AppKit branch.preprocessTextapplies its two rules by hand; the sentence-spacing and citation regexes are compiled once; the tilde scan walks code spans with one cursor; the table parser rejects lines with no|before splitting them into cells;strippingPendingTailinspects only the suffix; the streaming buffer projects a single text block once.ChatAssistantAnswerText.visiblederives the block-projection fallback only on the paths that return it, andhasVisibleanswers!isEmptywithout building the text.ChatBubblederives its row text once per body evaluation.ChatProvider+AssistantTextNormalization.swiftandcarryingLocalOnlyFieldstoChatProvider+JournalProjection.swift, soChatProvider.swiftshrinks under the agent-runtime convergence ceiling instead of growing.ChatJournalWriteCoordinator.schedule(coalescing:): a streaming write replaces the waiting one instead of queueing behind it (at most one in flight, one waiting, always the newest row); durable writes keep their place in line;cancelAlldrops a waiting snapshot.carryingLocalOnlyFieldskeeps the live row's text and blocks when a streaming echo is behind it;projectJournalTurnspublishes only when a row actually changed.INV-6 is unchanged: one
ChatProvider, kernel journal turns remain the durable source of truth (a terminal or kernel-owned row is never streaming and is taken whole; the coalesced streaming write still lands before terminalization), and no SwiftUI text selection enters the live transcript (check_chat_selection_boundary.pypasses).Measurements
All numbers below are from this branch on this Mac (Apple Silicon, macOS 26.6, debug builds). Details and raw profiles are in the session record.
Hermetic mounted transcript (
ChatStreamingRenderBudgetTests,xcrun swift test --package-path Desktop --filter ChatStreamingRenderBudgetTests): 20-row history with tool-call blocks, one row streaming a 6,777-character Markdown answer in 142 flushes of 48 characters at the buffer's 35 ms cadence. Main-thread CPU per flush, and the same cadence with nothing arriving ("idle", the streaming mark still animating):TimelineViewThe idle row is the headline: while a row streams, the mark's
TimelineViewalone kept the main thread fully busy (39.6 ms of every 35 ms) with no text arriving. The remaining ~15 ms per flush is SwiftUI/AppKit layout of the hosting view plus one incremental TextKit edit and one Markdown parse of the answer.Live app (named bundle
omi-chat-stream-perf, signed in, dev backend, real model; the same 1,500-word Markdown prompt throughask_main_chat_no_wait; main-actor round trips of the automation bridge's/statetimed every ~50 ms while the answer streamed, plussampleof the process):Idle hops are 0.5–0.9 ms on both. In the baseline profile the top main-thread costs were
visibleAnswerText/bodyIsBlockProjection(25% of samples), SwiftUI graph updates from the per-flush transcript rebuild,normalizeAssistantSentenceSpacingwith per-lineNSRegularExpressioncompiles (8%), the discarded SwiftUI parse, and the throwaway TextKit layout. After: what remains is the Foundation Markdown parse of the whole answer per flush (~4.5%), sentence-spacing normalization over the whole answer (~2.5%), hosting-view layout, and Core Animation re-uploading the tall text view's backing store. Those are follow-ups, not regressions.Note on the fast-lane bundle: a re-signed named bundle blocks its main thread inside
SecItemCopyMatchingon a keychain ACL prompt for the old signature; re-runningomi-auth-seed.shbefore relaunch avoids it. Unrelated to this change; recorded so the next measurement does not lose an hour to it.Tests
ChatStreamingRenderBudgetTests(new): mounts the production transcript through the gesture harness with tool-call-bearing history, streams a 6.8k-char Markdown answer in 142 buffer-sized flushes, and pins the per-flush work throughChatStreamingRenderProbe(DEBUG-only counters): no SwiftUI parse on the AppKit path, ≤1 whole-storage replacement, incremental edits for every later flush, ≤8 throwaway layouts, live-layout heights. Main-thread CPU per flush is printed, not asserted.ChatStreamingJournalCoalescingTests(new): 50 flushes behind one in-flight write execute as two writes (first, newest); durable writes keep ordering;cancelAlldrops a waiting snapshot; a streaming echo behind the live row neither moves the text back nor republishes; a terminal replay still replaces the row.ChatSelectableProseIncrementalEditTests(new): append edits only the tail; a closing**re-styles earlier words and the storage still equals the new prose; identical updates do not touch the storage; composed characters are never split; live height equals the throwaway height at three widths and declines other widths;preprocessTextmatches the original regular-expression rules on an edge-case oracle.Verification
xcrun swift test --package-path Desktop --filter 'ChatStreamingRenderBudgetTests|ChatStreamingJournalCoalescingTests|ChatSelectableProseIncrementalEditTests|ChatJournalWritePathTests|ChatTranscriptGestureHarnessTests|OmiMarkdown|ChatStreamingRevealTests|ChatStreamingTailProjectionTests|ChatProseRenderCacheTests|KernelTurnRecordedProjectionTests|ChatFollowUpChipTests|ChatWorkingIndicatorTests|MarkdownNestedScrollTests|ChatBubble|ChatTimelineContinuity|ChatMessage|ChatTranscript'— all green (theChatTimelineContinuityTestssource tripwire was updated for the renamedmessageContentView(_:rowText:); the ordering contract it checks is unchanged).python3 .github/scripts/check_chat_selection_boundary.py— passes.Product invariants affected
Behaviour under each is unchanged; their guard tests are in the suites above (the chat-scroll harness drives the streamed rows this change touches).
Failure-Class: none
Line-Count-Exception: desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift | 2119 -> 2148 | the row text is derived once per body evaluation; the memo lives beside the sites it replaces
Line-Count-Exception: desktop/macos/Desktop/Sources/MainWindow/Components/OmiMarkdown.swift | 1672 -> 1716 | hand-rolled header/bullet preprocessing replaces per-line regex compiles on the streaming path
🤖 Generated with Claude Code