Skip to content

fix(mothership): stop chat perf decay from permanently-animated streamed messages#5411

Merged
TheodoreSpeaks merged 9 commits into
stagingfrom
fix/mothership-performance
Jul 4, 2026
Merged

fix(mothership): stop chat perf decay from permanently-animated streamed messages#5411
TheodoreSpeaks merged 9 commits into
stagingfrom
fix/mothership-performance

Conversation

@TheodoreSpeaks

@TheodoreSpeaks TheodoreSpeaks commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Chat perf decay (the main fix)

  • Chat got progressively laggier over a long session because every streamed message stayed in Streamdown's animated pipeline forever — one span per character, tens of thousands of permanent DOM nodes in the mounted transcript. Refresh fixed it because reloaded transcripts render static.
  • Settled messages now remount to a span-free tree 300ms after the reveal drains. Remount via `key` is required: streamdown 2.5 memoizes its default element components on className + source position, so flipping `isAnimating` off leaves stale span DOM in place (verified with a standalone repro). The settled instance keeps the streaming parser (`mode`), so the swap trades byte-identical pixels — no flash on replies with unbalanced markdown.
  • Mid-stream: `sep: 'char'` → `'word'` (~5x fewer spans rebuilt per reveal tick; visually identical with `stagger: 0`), and fades stop past 6k revealed chars in one segment so walls of text can't swamp the frame budget.
  • Unified the plain-text and special-tag render branches: most replies gain a trailing `` tag (suggested follow-ups) at the very end, and the old branch switch re-parented Streamdown at that moment — remounting it and re-fading the whole message from transparent (the long-standing "flash at the conclusion", predates this PR).
  • Animation latches reset when a reused ChatContent instance receives replaced (non-append) content, so regenerated turns still animate; latches held in `useState` per the updated render-phase hook rules.
  • Measured mid-stream on a single giant paragraph: p95 frame time 49.9ms → 9.4ms, long tasks 1256ms → 0 per 12s window; at ~10k chars: p95 83ms → 10ms. Spans drain to 0 after every turn; swap is pixel-stable (0 height/text delta).

Text-selection follow-up (regression from #5389 that this branch merged in)

  • The global solid-brand + forced-white `::selection` rendered black-on-blue in the chat input and every workflow subblock editor — those draw text in a mirror overlay the forced white can never reach (and Chromium ignores `::selection` color in form controls anyway).
  • Kept one global rule, made it lighter across the board per Waleed's suggestion: `::selection { background-color: var(--selection-muted) }` (`#1a5cf647` light / `#4b83f759` dark), no text-color override so selected text keeps its own paint on every surface. Landing keeps a consistent, non-dimming branded selection; verified on landing, chat prose, and the overlay inputs in both themes.

Type of Change

  • Bug fix

Testing

Tested manually in the live app (streamed multi-turn chats; measured rAF frame deltas + long tasks before/after; DOM-diffed the settle swap; verified 0 animated spans after settle, no completion flash, no re-fade when follow-ups arrive; selection checked on landing/chat/inputs in light + dark). `vitest` message-content + smooth-text + file-viewer suites pass, `bun run lint`, `bun run check:api-validation:strict` pass.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@vercel

vercel Bot commented Jul 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jul 4, 2026 7:43pm

Request Review

@cursor

cursor Bot commented Jul 4, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Chat rendering lifecycle is substantially more complex (remount timing, latch resets, unified tree); regressions could show as completion flashes, missing fades, or wrong static/streaming state on regeneration.

Overview
Fixes progressive chat lag in long sessions by tearing down Streamdown’s per-token animation DOM after each reply finishes, and by cheaper mid-stream fades. Settled messages remount Streamdown (~300ms after reveal) with a streamsettled key so animation spans are dropped while the streaming markdown parser stays latched (avoids re-parse flash). Mid-stream, reveal animation uses word instead of char separation, and fades stop after ~6k revealed chars in one segment.

Also unifies plain-text and special-tag rendering so a trailing <options> block no longer re-parents markdown and re-fades the whole message. Latches reset when content is replaced (not appended) on a reused row instance.

Global CSS: ::selection uses new translucent --selection-muted instead of solid brand + forced white, so selection stays readable in mirrored chat/workflow inputs and form controls.

Reviewed by Cursor Bugbot for commit 5edddda. Bugbot is set up for automated code reviews on this repo. Configure here.

@greptile-apps

greptile-apps Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a progressive performance degradation in long chat sessions caused by Streamdown's animated pipeline accumulating thousands of permanent <span data-sd-animate> DOM nodes (one per character) for every streamed message. A page refresh fixed it because reloaded history renders in static mode, while live-streamed messages stayed in the animated pipeline forever.

  • Animated→static lifecycle: Adds animationDrained state that flips 300ms after the reveal completes, then remounts Streamdown via key ('stream''settled') to shed the per-character spans — intentionally using the same streaming parser (parserTree stays latched) to avoid a flash from re-parsing unbalanced markdown through a different pipeline.
  • Mid-stream span reduction: Switches sep: 'char' to sep: 'word' (≈5× fewer spans rebuilt per tick), and introduces a 6 000-char fadeCutoff latch that stops new fade-in spans from being created on very long paragraphs while leaving the streaming parser running — measured p95 frame time dropping from 49.9 ms to 9.4 ms on a single large paragraph.
  • Unified render path: Collapses the previous two-branch return (with-/without-special-tags) into one, preventing Streamdown from re-mounting (and re-fading the whole message) when a trailing <options> block appears at completion.

Confidence Score: 5/5

Safe to merge — well-scoped rendering optimisation with careful state transitions and no regressions on the existing test suite.

All three state latches (streamedThisSession, animationDrained, fadeCutoff) are one-way and correctly gated: the drain timer fires only after reveal settles, the key swap remounts Streamdown without touching the streaming parser, and the content-replacement reset is guarded by animationDrained so it cannot fire mid-stream. The unified render path removes the structural branch that caused re-mount flashes. No logic paths loop or leave state inconsistent.

No files require special attention.

Important Files Changed

Filename Overview
apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx Core streaming lifecycle refactor — adds three one-way state latches (streamedThisSession, animationDrained, fadeCutoff), a drain timer, and a sep+cutoff mid-stream optimisation; all transitions are carefully gated and the comments match the code. Unified render path eliminates the two-branch return that caused re-mount flashes.
apps/sim/app/_styles/globals.css Adds --selection-muted CSS variable (translucent brand blue) and switches ::selection to use it without a forced color override; fixes broken selection rendering in form controls and transparent-textarea chat inputs. The edit touches a globally-scoped pseudo-element that cannot live in a component stylesheet.

Reviews (2): Last reviewed commit: "improvement(styling): one lighter transl..." | Re-trigger Greptile

* re-fade the entire visible segment.
*/
if (streamedContent.length > FADE_MAX_REVEALED_CHARS) fadeCutoffRef.current = true
const fadeActive = streamingTree && !fadeCutoffRef.current

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale fade cutoff after replace

Medium Severity

When displayContent is replaced while animationDrained is still false, latch reset skips fadeCutoffRef even though the instance can be reused for a new logical message. If the prior message had crossed FADE_MAX_REVEALED_CHARS, the new stream keeps fadeActive false for the rest of that mount, so reveal fades never run for the replacement content.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3961a9c. Configure here.

/>
)
})}
{parsed.hasPendingTag && isRevealing && <PendingTagIndicator />}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Whitespace-only text not rendered

Low Severity

Plain replies without special tags now always go through flushMarkdown, which only enqueues inline groups when pendingMarkdown.trim() is truthy. Whitespace-only text still becomes a text segment via parseSpecialTags, but it never produces an inline group, so Streamdown is omitted and the message body renders empty.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3961a9c. Configure here.

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 56f391b. Configure here.

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

@TheodoreSpeaks TheodoreSpeaks merged commit 818fa00 into staging Jul 4, 2026
18 checks passed
@TheodoreSpeaks TheodoreSpeaks deleted the fix/mothership-performance branch July 4, 2026 19:52
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