Skip to content

Commit 818fa00

Browse files
fix(mothership): stop chat perf decay from permanently-animated streamed messages (#5411)
* fix(mothership): stop chat perf decay from permanently-animated streamed messages * fix(mothership): reset animation latches when a reused ChatContent gets replaced content * fix(mothership): keep streaming parser on settled messages to kill the drain-swap flash * fix(mothership): unify plain/special render branches to stop whole-message re-fade when options arrive * improvement(mothership): hold render-phase animation latches in useState per updated hook rules * fix(styling): translucent text-selection in form controls so field text stays readable * improvement(styling): one lighter translucent text-selection color everywhere, no forced text color * chore(styling): selection-muted tokens as 8-digit hex to match color token convention
1 parent 0249516 commit 818fa00

2 files changed

Lines changed: 200 additions & 101 deletions

File tree

apps/sim/app/_styles/globals.css

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ html.sidebar-booting .sidebar-shell-inner {
225225
--brand-accent: #33c482;
226226
--brand-accent-hover: #2dac72;
227227
--selection: #1a5cf6;
228+
--selection-muted: #1a5cf647;
228229
--warning: #ea580c;
229230

230231
/* Inverted surface (dark chrome on light page, e.g. tag dropdown) */
@@ -382,6 +383,7 @@ html.sidebar-booting .sidebar-shell-inner {
382383
--brand-accent: #33c482;
383384
--brand-accent-hover: #2dac72;
384385
--selection: #4b83f7;
386+
--selection-muted: #4b83f759;
385387
--warning: #ff6600;
386388

387389
/* Inverted surface (in dark mode, falls back to standard surfaces) */
@@ -492,10 +494,14 @@ html.sidebar-booting .sidebar-shell-inner {
492494
}
493495

494496
/* Consistent branded text-selection color everywhere, independent of the
495-
browser/OS default (which dims when the window loses focus). */
497+
browser/OS default (which dims when the window loses focus). Translucent,
498+
with no color override, so selected text keeps its own paint on every
499+
surface — solid-brand + forced white broke wherever glyphs aren't the
500+
selected element's own (Chromium ignores ::selection color in form
501+
controls, and the chat/workflow inputs render transparent textareas over
502+
styled mirrors, which left black text on solid brand blue). */
496503
::selection {
497-
background-color: var(--selection);
498-
color: var(--white);
504+
background-color: var(--selection-muted);
499505
}
500506

501507
body {

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx

Lines changed: 191 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { type ComponentPropsWithoutRef, memo, useEffect, useMemo, useRef } from 'react'
3+
import { type ComponentPropsWithoutRef, memo, useEffect, useMemo, useRef, useState } from 'react'
44
import { Streamdown } from 'streamdown'
55
import 'streamdown/styles.css'
66
// prismjs core must load before its language components — they register on the
@@ -53,17 +53,42 @@ const PROSE_CLASSES = cn(
5353

5454
/**
5555
* Soft fade for newly revealed text. Paired with {@link useSmoothText}, which
56-
* paces the reveal: `sep: 'char'` fades each character as the pacer exposes it
57-
* (so a growing trailing word never re-animates), and `stagger: 0` keeps the
58-
* cadence driven by the pacer rather than an overlapping per-token delay ramp.
56+
* paces the reveal; `stagger: 0` keeps the cadence driven by the pacer rather
57+
* than an overlapping per-token delay ramp — every span revealed in one tick
58+
* fades as a unit, so `sep: 'word'` looks identical to `sep: 'char'` while
59+
* creating ~5x fewer spans. That span count is the dominant mid-stream cost:
60+
* the animate plugin rebuilds a span per token for the WHOLE trailing block on
61+
* every reveal tick, so per-char wrapping of a long paragraph meant thousands
62+
* of hast nodes + React elements reconciled ~40x/sec. Streamdown's
63+
* prev-content tracking keeps a word that grows across two ticks from
64+
* re-fading (its continuation renders unfaded), and the pacer's word-boundary
65+
* snapping makes such splits rare to begin with.
5966
*/
6067
const STREAM_ANIMATION = {
6168
animation: 'fadeIn',
6269
duration: 220,
6370
stagger: 0,
64-
sep: 'char',
71+
sep: 'word',
6572
} as const
6673

74+
/**
75+
* How long after the reveal fully settles before the animated tree is dropped.
76+
* Must exceed {@link STREAM_ANIMATION}'s 220ms duration so the last characters
77+
* finish fading at full opacity before their spans are swapped for plain text.
78+
*/
79+
const ANIMATION_DRAIN_MS = 300
80+
81+
/**
82+
* Once a segment has revealed this many characters, new text stops fading in;
83+
* the word-paced reveal itself is unchanged. Fade cost scales with segment
84+
* length — every reveal tick rebuilds a span per word for the WHOLE trailing
85+
* markdown block — so on an unbroken wall of text it eventually swamps the
86+
* frame budget (measured: ~9k-char single paragraphs spent ~30% of main-thread
87+
* time in long tasks) while the fade itself is imperceptible detail that deep
88+
* into a reply.
89+
*/
90+
const FADE_MAX_REVEALED_CHARS = 6000
91+
6792
function startsInlineWord(value: string): boolean {
6893
return /^[A-Za-z0-9_(]/.test(value)
6994
}
@@ -306,19 +331,91 @@ function ChatContentInner({
306331
}, [isRevealing])
307332

308333
/**
309-
* One-way latch: once a message has streamed in this mount, keep rendering it
310-
* through Streamdown's streaming/animation pipeline for the rest of its life.
311-
* Drives `mode`, `animated`, AND `isAnimating` together — all three must stay
312-
* constant across the completion boundary. Streamdown removes the per-word
313-
* `<span>` wrappers (and re-parses the whole message) the instant `isAnimating`
314-
* goes false, so wiring `isAnimating` to `isRevealing` (which flips at
315-
* completion) reintroduces the streaming→static flash this latch exists to
316-
* prevent. Content is stable once revealed, so a permanently-true
317-
* `isAnimating` never re-fades anything.
334+
* Streaming-tree lifecycle. While a message streams (and until its reveal
335+
* drains), it renders through Streamdown's streaming/animated pipeline, whose
336+
* animate plugin wraps every character in its own `<span data-sd-animate>` —
337+
* thousands of DOM nodes per streamed message. Holding that tree forever made
338+
* long sessions progressively laggier until a refresh (which renders the same
339+
* transcript static). `animationDrained` flips one-way
340+
* {@link ANIMATION_DRAIN_MS} after the reveal settles and swaps to the static
341+
* pipeline; the drain window lets the last 220ms fades finish so the swap
342+
* trades identical pixels, unlike flipping at `isRevealing`'s edge, which cut
343+
* running fades short (the old completion flash).
344+
*
345+
* The swap must REMOUNT Streamdown (via `key`), not just flip its props:
346+
* Streamdown's default element components are memoized on className + source
347+
* position (`E`/`qe` in streamdown 2.5), so a re-parse of unchanged content
348+
* without the animate plugin bails at every unoverridden element (`p`,
349+
* `strong`, `tr`, headings, …) and leaves the stale per-char span DOM in
350+
* place. The settled instance keeps the streaming parser (`parserTree`
351+
* below) so the remount only sheds the spans, never re-interprets the
352+
* markdown.
353+
*
354+
* The drain is deliberately one-way: a stream that resumes afterwards
355+
* (reconnect/continuation) reveals paced but unfaded, because re-arming
356+
* mounts a fresh animate plugin with no prev-content tracking, which would
357+
* re-fade the entire already-visible message.
318358
*/
319-
const streamedThisSession = useRef(false)
320-
if (isStreaming) streamedThisSession.current = true
321-
const keepStreamingTree = isRevealing || streamedThisSession.current
359+
const [streamedThisSession, setStreamedThisSession] = useState(false)
360+
const [animationDrained, setAnimationDrained] = useState(false)
361+
const [fadeCutoff, setFadeCutoff] = useState(false)
362+
363+
/**
364+
* The per-session latches above outlive the content when React reuses this
365+
* instance for a different logical message — parent rows key by turn
366+
* position and text segments by run ordinal (both deliberately stable across
367+
* the live→persisted id swap), so an ordinal shift or regeneration can hand
368+
* a settled instance brand-new content whose stale `animationDrained` would
369+
* silently render the new stream static. Reset the latches when the content
370+
* is REPLACED (not an append of the previous string) after the instance has
371+
* settled. A resumed turn only ever appends, so this never undoes the
372+
* one-way drain; mid-stream sanitize rewrites are excluded by the
373+
* `animationDrained` gate (the drain only fires after settle). All latches
374+
* are render-phase `useState` adjustments (prev-tracker idiom), not refs —
375+
* they are read during render, and state is concurrent-safe where a
376+
* render-phase ref mutation is not.
377+
*/
378+
const [prevDisplayContent, setPrevDisplayContent] = useState(displayContent)
379+
if (prevDisplayContent !== displayContent) {
380+
setPrevDisplayContent(displayContent)
381+
if (!displayContent.startsWith(prevDisplayContent) && animationDrained) {
382+
setStreamedThisSession(false)
383+
setFadeCutoff(false)
384+
setAnimationDrained(false)
385+
}
386+
}
387+
388+
if (isStreaming && !streamedThisSession) setStreamedThisSession(true)
389+
390+
useEffect(() => {
391+
if (isRevealing || animationDrained || !streamedThisSession) return
392+
const timeout = setTimeout(() => setAnimationDrained(true), ANIMATION_DRAIN_MS)
393+
return () => clearTimeout(timeout)
394+
}, [isRevealing, animationDrained, streamedThisSession])
395+
396+
/**
397+
* `parserTree` (drives `mode`) stays latched for the mount's life: streaming
398+
* mode is the only one that applies remend/incomplete-markdown repair and
399+
* block-split parsing, so a settled message must KEEP the streaming parser —
400+
* swapping to `mode='static'` at drain re-parses the same source through a
401+
* different pipeline (no remend, whole-doc parse) and visibly flashes on any
402+
* reply with unbalanced markdown. `streamingTree` (drives the remount key
403+
* and animation props) additionally drops at drain, so the settled instance
404+
* re-renders through the SAME parser minus the per-word animation spans —
405+
* byte-identical pixels. Only never-streamed mounts (reloaded history)
406+
* render static.
407+
*/
408+
const parserTree = isRevealing || streamedThisSession
409+
const streamingTree = parserTree && !animationDrained
410+
411+
/**
412+
* One-way fade cutoff (see {@link FADE_MAX_REVEALED_CHARS}). Latched so a
413+
* sanitize-induced content shrink back across the boundary cannot re-arm
414+
* `animated` — a fresh animate plugin has no prev-content tracking and would
415+
* re-fade the entire visible segment.
416+
*/
417+
if (!fadeCutoff && streamedContent.length > FADE_MAX_REVEALED_CHARS) setFadeCutoff(true)
418+
const fadeActive = streamingTree && !fadeCutoff
322419

323420
useEffect(() => {
324421
const handler = (e: Event) => {
@@ -338,93 +435,89 @@ function ChatContentInner({
338435
() => parseSpecialTags(streamedContent, isRevealing),
339436
[streamedContent, isRevealing]
340437
)
341-
const hasSpecialContent = parsed.hasPendingTag || parsed.segments.some((s) => s.type !== 'text')
342-
343-
if (hasSpecialContent) {
344-
type BlockSegment = Exclude<
345-
ContentSegment,
346-
{ type: 'text' } | { type: 'thinking' } | { type: 'workspace_resource' }
347-
>
348-
type RenderGroup =
349-
| { kind: 'inline'; markdown: string }
350-
| { kind: 'block'; segment: BlockSegment; index: number }
351-
352-
const groups: RenderGroup[] = []
353-
let pendingMarkdown = ''
354-
355-
const flushMarkdown = () => {
356-
if (pendingMarkdown.trim()) {
357-
groups.push({ kind: 'inline', markdown: pendingMarkdown })
358-
}
359-
pendingMarkdown = ''
360-
}
361438

362-
for (let i = 0; i < parsed.segments.length; i++) {
363-
const s = parsed.segments[i]
364-
const nextSegment = parsed.segments[i + 1]
365-
if (s.type === 'workspace_resource') {
366-
// Files are addressed by their encoded VFS path (copied verbatim from the tag);
367-
// workflows/tables/KBs by id. The angle-bracket link destination keeps the path
368-
// intact through markdown parsing (tolerates parens) without re-encoding it.
369-
const ref = s.data.type === 'file' ? (s.data.path ?? s.data.id ?? '') : (s.data.id ?? '')
370-
const label = s.data.title || ref
371-
pendingMarkdown = appendInlineReferenceMarkdown(
372-
pendingMarkdown,
373-
`[${label}](<#wsres-${s.data.type}-${ref}>)`,
374-
nextSegment
375-
)
376-
} else if (s.type === 'text' || s.type === 'thinking') {
377-
pendingMarkdown += s.content
378-
} else {
379-
flushMarkdown()
380-
groups.push({ kind: 'block', segment: s, index: i })
381-
}
439+
type BlockSegment = Exclude<
440+
ContentSegment,
441+
{ type: 'text' } | { type: 'thinking' } | { type: 'workspace_resource' }
442+
>
443+
type RenderGroup =
444+
| { kind: 'inline'; markdown: string }
445+
| { kind: 'block'; segment: BlockSegment; index: number }
446+
447+
const groups: RenderGroup[] = []
448+
let pendingMarkdown = ''
449+
450+
const flushMarkdown = () => {
451+
if (pendingMarkdown.trim()) {
452+
groups.push({ kind: 'inline', markdown: pendingMarkdown })
382453
}
383-
flushMarkdown()
454+
pendingMarkdown = ''
455+
}
384456

385-
return (
386-
<div className='space-y-3'>
387-
{groups.map((group, i) => {
388-
if (group.kind === 'inline') {
389-
return (
390-
<div
391-
key={`inline-${i}`}
392-
className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}
393-
>
394-
<Streamdown
395-
mode={keepStreamingTree ? undefined : 'static'}
396-
animated={keepStreamingTree ? STREAM_ANIMATION : false}
397-
isAnimating={keepStreamingTree}
398-
components={MARKDOWN_COMPONENTS}
399-
>
400-
{group.markdown}
401-
</Streamdown>
402-
</div>
403-
)
404-
}
405-
return (
406-
<SpecialTags
407-
key={`special-${group.index}`}
408-
segment={group.segment}
409-
onOptionSelect={onOptionSelect}
410-
/>
411-
)
412-
})}
413-
{parsed.hasPendingTag && isRevealing && <PendingTagIndicator />}
414-
</div>
415-
)
457+
for (let i = 0; i < parsed.segments.length; i++) {
458+
const s = parsed.segments[i]
459+
const nextSegment = parsed.segments[i + 1]
460+
if (s.type === 'workspace_resource') {
461+
// Files are addressed by their encoded VFS path (copied verbatim from the tag);
462+
// workflows/tables/KBs by id. The angle-bracket link destination keeps the path
463+
// intact through markdown parsing (tolerates parens) without re-encoding it.
464+
const ref = s.data.type === 'file' ? (s.data.path ?? s.data.id ?? '') : (s.data.id ?? '')
465+
const label = s.data.title || ref
466+
pendingMarkdown = appendInlineReferenceMarkdown(
467+
pendingMarkdown,
468+
`[${label}](<#wsres-${s.data.type}-${ref}>)`,
469+
nextSegment
470+
)
471+
} else if (s.type === 'text' || s.type === 'thinking') {
472+
pendingMarkdown += s.content
473+
} else {
474+
flushMarkdown()
475+
groups.push({ kind: 'block', segment: s, index: i })
476+
}
416477
}
478+
flushMarkdown()
417479

480+
/**
481+
* Plain text and special-tag content share ONE render structure. A message
482+
* with no special tags is simply a single inline group — it must NOT get a
483+
* dedicated JSX branch, because most replies gain a trailing `<options>` tag
484+
* (suggested follow-ups) at the very end, and switching branches at that
485+
* moment re-parents the Streamdown to a different tree position. React then
486+
* remounts it with a fresh animate plugin and the ENTIRE message re-fades
487+
* from transparent — the "flash at the conclusion". With the unified
488+
* structure the leading text group keeps its position (`inline-0`) and only
489+
* the new special block mounts.
490+
*/
418491
return (
419-
<div className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}>
420-
<Streamdown
421-
mode={keepStreamingTree ? undefined : 'static'}
422-
animated={keepStreamingTree ? STREAM_ANIMATION : false}
423-
isAnimating={keepStreamingTree}
424-
components={MARKDOWN_COMPONENTS}
425-
>
426-
{streamedContent}
427-
</Streamdown>
492+
<div className='space-y-3'>
493+
{groups.map((group, i) => {
494+
if (group.kind === 'inline') {
495+
return (
496+
<div
497+
key={`inline-${i}`}
498+
className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}
499+
>
500+
<Streamdown
501+
key={streamingTree ? 'stream' : 'settled'}
502+
mode={parserTree ? undefined : 'static'}
503+
animated={fadeActive ? STREAM_ANIMATION : false}
504+
isAnimating={streamingTree}
505+
components={MARKDOWN_COMPONENTS}
506+
>
507+
{group.markdown}
508+
</Streamdown>
509+
</div>
510+
)
511+
}
512+
return (
513+
<SpecialTags
514+
key={`special-${group.index}`}
515+
segment={group.segment}
516+
onOptionSelect={onOptionSelect}
517+
/>
518+
)
519+
})}
520+
{parsed.hasPendingTag && isRevealing && <PendingTagIndicator />}
428521
</div>
429522
)
430523
}

0 commit comments

Comments
 (0)