Skip to content

Commit 9aa2a51

Browse files
authored
improvement(mothership): smooth streamed text reveal + dropdown z-index fix (#4947)
* fix(emcn): render dropdown menus above modals so in-modal dropdowns are clickable The base DropdownMenuContent defaulted to --z-dropdown (100), below the modal at --z-modal (200) — the only Radix popper that sat below the modal (Popover/Tooltip/Toast all sit above). Since the modal overlay is semi-transparent, an in-modal dropdown was faintly visible but intercepted no clicks, which forced one-off z-popover overrides on ChipDropdown and ChipSelect. Move the DropdownMenu base to the popover layer (--z-popover, above the modal) and drop the redundant per-component overrides, so every menu — including the 39 raw DropdownMenu consumers — is clickable inside a chip modal from a single source of truth. The --z-dropdown variable stays at 100 for the in-flow panels that intentionally sit below modals. * improvement(mothership): smooth streamed text reveal and fix completion flash Port opencode's paced word-boundary reveal into useSmoothText so streamed text builds smoothly regardless of how the model chunks deltas, and keep it smooth through completion: - Reveal on a steady 24ms timer in tiered steps that snap to word/punctuation boundaries instead of revealing partial tokens. - Drain the lagging tail at the paced cadence on stream end instead of snapping; the consumer holds streaming render until the reveal catches up. - Pin a streamed message to Streamdown's streaming mode for its mounted lifetime so the static-mode swap doesn't remount and re-highlight the message. - Key the assistant row by its owning user message id so the live->persisted id swap no longer remounts the row (whole-message blink) at completion. * docs(emcn): trim z-index scale comment to original footprint Correct the pre-existing scale comment in place (the old wording became stale when DropdownMenu moved to the popover layer) rather than expanding it. The scale tokens are global, so their documentation stays with them.
1 parent bc371b0 commit 9aa2a51

7 files changed

Lines changed: 102 additions & 78 deletions

File tree

apps/sim/app/_styles/globals.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
--auth-primary-btn-hover-border: #e0e0e0;
2222
--auth-primary-btn-hover-text: #000000;
2323

24-
/* z-index scale for layered UI
25-
Popover must be above modal so dropdowns inside modals render correctly */
24+
/* z-index scale. Transient poppers (menus, selects, popovers, tooltips, toasts)
25+
sit above --z-modal so they stay clickable over the semi-transparent overlay. */
2626
--z-dropdown: 100;
2727
--z-modal: 200;
2828
--z-popover: 300;

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

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,11 @@ function ChatContentInner({
292292

293293
const displayContent = useMemo(() => sanitizeChatDisplayContent(content), [content])
294294
const streamedContent = useSmoothText(displayContent, isStreaming)
295+
const isRevealing = isStreaming || streamedContent.length < displayContent.length
296+
297+
const streamedThisSession = useRef(false)
298+
if (isStreaming) streamedThisSession.current = true
299+
const keepStreamingTree = isRevealing || streamedThisSession.current
295300

296301
useEffect(() => {
297302
const handler = (e: Event) => {
@@ -308,8 +313,8 @@ function ChatContentInner({
308313
}, [])
309314

310315
const parsed = useMemo(
311-
() => parseSpecialTags(streamedContent, isStreaming),
312-
[streamedContent, isStreaming]
316+
() => parseSpecialTags(streamedContent, isRevealing),
317+
[streamedContent, isRevealing]
313318
)
314319
const hasSpecialContent = parsed.hasPendingTag || parsed.segments.some((s) => s.type !== 'text')
315320

@@ -365,9 +370,9 @@ function ChatContentInner({
365370
className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}
366371
>
367372
<Streamdown
368-
mode={isStreaming ? undefined : 'static'}
369-
animated={isStreaming ? STREAM_ANIMATION : false}
370-
isAnimating={isStreaming}
373+
mode={keepStreamingTree ? undefined : 'static'}
374+
animated={keepStreamingTree ? STREAM_ANIMATION : false}
375+
isAnimating={isRevealing}
371376
components={MARKDOWN_COMPONENTS}
372377
>
373378
{group.markdown}
@@ -383,17 +388,17 @@ function ChatContentInner({
383388
/>
384389
)
385390
})}
386-
{parsed.hasPendingTag && isStreaming && <PendingTagIndicator />}
391+
{parsed.hasPendingTag && isRevealing && <PendingTagIndicator />}
387392
</div>
388393
)
389394
}
390395

391396
return (
392397
<div className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}>
393398
<Streamdown
394-
mode={isStreaming ? undefined : 'static'}
395-
animated={isStreaming ? STREAM_ANIMATION : false}
396-
isAnimating={isStreaming}
399+
mode={keepStreamingTree ? undefined : 'static'}
400+
animated={keepStreamingTree ? STREAM_ANIMATION : false}
401+
isAnimating={isRevealing}
397402
components={MARKDOWN_COMPONENTS}
398403
>
399404
{streamedContent}

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,20 @@ export function MothershipChat({
223223
}
224224
return out
225225
}, [messages])
226+
const assistantTurnKeyByIndex = useMemo(() => {
227+
const out: string[] = []
228+
let lastUserId: string | undefined
229+
let ordinal = 0
230+
for (const [index, message] of messages.entries()) {
231+
if (message.role === 'user') {
232+
lastUserId = message.id
233+
ordinal = 0
234+
} else {
235+
out[index] = lastUserId ? `assistant:${lastUserId}:${ordinal++}` : message.id
236+
}
237+
}
238+
return out
239+
}, [messages])
226240
const initialScrollDoneRef = useRef(false)
227241
const userInputRef = useRef<UserInputHandle>(null)
228242

@@ -297,7 +311,7 @@ export function MothershipChat({
297311
const isLast = index === messages.length - 1
298312
return (
299313
<AssistantMessageRow
300-
key={msg.id}
314+
key={assistantTurnKeyByIndex[index] ?? msg.id}
301315
message={msg}
302316
isStreaming={isStreamActive && isLast}
303317
precedingUserContent={precedingUserContentByIndex[index]}

apps/sim/components/emcn/components/chip-dropdown/chip-dropdown.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,6 @@ const ChipDropdown = forwardRef<HTMLButtonElement, ChipDropdownProps>(
291291
align={align}
292292
onOpenAutoFocus={searchable ? (event) => event.preventDefault() : undefined}
293293
className={cn(
294-
'z-[var(--z-popover)]',
295294
matchTriggerWidth && 'w-[var(--radix-dropdown-menu-trigger-width)] max-w-none',
296295
contentClassName
297296
)}

apps/sim/components/emcn/components/chip-select/chip-select.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ export function ChipSelect({
238238
align={align}
239239
onOpenAutoFocus={searchable ? (e) => e.preventDefault() : undefined}
240240
style={contentStyle}
241-
className={cn('z-[var(--z-popover)] min-w-[160px]', contentClassName)}
241+
className={cn('min-w-[160px]', contentClassName)}
242242
>
243243
{searchable ? (
244244
<DropdownMenuSearchInput

apps/sim/components/emcn/components/dropdown-menu/dropdown-menu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const ANIMATION_CLASSES =
3030
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=open]:animate-in motion-reduce:animate-none'
3131

3232
const CONTENT_BASE_CLASSES =
33-
'z-[var(--z-dropdown)] max-h-[240px] min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-y-auto overflow-x-hidden overscroll-none border border-[var(--border)] bg-[var(--bg)] p-1.5 text-[var(--text-body)] shadow-sm'
33+
'z-[var(--z-popover)] max-h-[240px] min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-y-auto overflow-x-hidden overscroll-none border border-[var(--border)] bg-[var(--bg)] p-1.5 text-[var(--text-body)] shadow-sm'
3434

3535
const DropdownMenu = DropdownMenuPrimitive.Root
3636

apps/sim/hooks/use-smooth-text.ts

Lines changed: 69 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,42 @@
11
import { useEffect, useRef, useState } from 'react'
22

33
/**
4-
* Per-frame reveal speed is proportional to how far behind the display is, so a
5-
* large burst drains quickly while a trickle reveals gently. `target / DIVISOR`
6-
* gives an ease-out feel; the clamps keep it from stalling or jumping.
4+
* Paced reveal of a growing string, ported from opencode's `createPacedValue`
5+
* (`packages/ui/src/components/message-part.tsx`). Instead of revealing a fixed
6+
* number of characters per animation frame, it advances on a steady ~24ms timer
7+
* in small tiered steps that SNAP to the next word/punctuation boundary — so
8+
* text appears word-by-word at a calm, even cadence regardless of how bursty the
9+
* upstream model deltas are. The boundary snapping is what keeps it from reading
10+
* as "blocky": a reveal never stops mid-word.
711
*/
8-
const REVEAL_DIVISOR = 6
9-
const MIN_STEP = 1
10-
const MAX_STEP = 400
12+
const PACE_MS = 24
13+
const SNAP = /[\s.,!?;:)\]]/
14+
15+
/**
16+
* Characters to advance per tick as a function of how far the reveal is behind.
17+
* Small backlogs trickle (2–8 chars); large backlogs accelerate but stay capped
18+
* so a burst is spread over several ticks rather than dumped at once.
19+
*/
20+
function step(remaining: number): number {
21+
if (remaining <= 12) return 2
22+
if (remaining <= 48) return 4
23+
if (remaining <= 96) return 8
24+
return Math.min(24, Math.ceil(remaining / 8))
25+
}
26+
27+
/**
28+
* Advance from `start` by `step(...)`, then extend up to 8 more characters to
29+
* land just past the next word/punctuation boundary so the reveal lands on a
30+
* whole word rather than mid-token.
31+
*/
32+
function nextIndex(text: string, start: number): number {
33+
const end = Math.min(text.length, start + step(text.length - start))
34+
const max = Math.min(text.length, end + 8)
35+
for (let i = end; i < max; i++) {
36+
if (SNAP.test(text[i] ?? '')) return i + 1
37+
}
38+
return end
39+
}
1140

1241
/**
1342
* Content already longer than this when streaming begins is assumed to be
@@ -31,13 +60,25 @@ interface SmoothTextOptions {
3160
}
3261

3362
/**
34-
* Paces a growing string so it reveals at a steady cadence regardless of how
35-
* bursty the upstream stream is — the client-side analogue of the AI SDK's
36-
* `smoothStream`. Returns the portion of `content` that should be displayed now.
63+
* Paces a growing string so it reveals word-by-word at a steady cadence
64+
* regardless of how bursty the upstream stream is — a React port of opencode's
65+
* paced text rendering. Returns the portion of `content` that should be
66+
* displayed now.
67+
*
68+
* Content that is already complete at mount (history, or a resume past
69+
* {@link RESUME_SKIP_THRESHOLD}) is returned in full and never animates. When a
70+
* live stream ends mid-reveal the remaining tail keeps draining at the paced
71+
* cadence rather than snapping — so the reveal stays smooth right to the end and
72+
* the caller can hold its streaming render until `useSmoothText` reports the
73+
* full string, avoiding a flash on the streaming→static handoff.
3774
*
38-
* While `isStreaming` is false the full string is returned unchanged (history
39-
* and completed turns never animate). When streaming ends mid-reveal the
40-
* remaining tail is shown immediately so nothing is left hidden.
75+
* @remarks
76+
* The reveal loop keys on `hasBacklog` rather than `content` so a new chunk on
77+
* every render does not re-subscribe the timer (and trip React's
78+
* max-update-depth guard); the running tick reads the latest value from a ref.
79+
* If upstream sanitization rewrites earlier text and shrinks the string, the
80+
* cursor is pulled back to the new end so regrowth stays paced instead of
81+
* jumping past it.
4182
*/
4283
export function useSmoothText(
4384
content: string,
@@ -51,15 +92,10 @@ export function useSmoothText(
5192
)
5293

5394
const contentRef = useRef(content)
54-
const streamingRef = useRef(isStreaming)
5595
const revealedRef = useRef(revealed)
56-
const frameRef = useRef<number | null>(null)
96+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
5797
const prevContentRef = useRef(content)
5898

59-
// A non-append rewrite (e.g. a patch replacing earlier text) must be shown in
60-
// full at once — re-revealing a prefix of rewritten content would look like
61-
// the document is retyping itself. Adjust during render so the slice below
62-
// never flashes a stale prefix.
6399
let effectiveRevealed = revealed
64100
if (
65101
snapOnNonAppend &&
@@ -74,72 +110,42 @@ export function useSmoothText(
74110
prevContentRef.current = content
75111

76112
contentRef.current = content
77-
streamingRef.current = isStreaming
78-
79-
// Key the reveal loop to streaming + remaining backlog, NOT to `content`:
80-
// `content` changes on every streamed chunk, and re-subscribing an rAF + setState
81-
// loop on each change is the "a dependency changes on every render" pattern that
82-
// trips React's max-update-depth guard. The running tick reads the latest content
83-
// from `contentRef`, so new chunks are absorbed without per-chunk teardown;
84-
// `hasBacklog` only flips when the reveal falls behind or catches up.
85-
if (!isStreaming && effectiveRevealed !== content.length) {
86-
effectiveRevealed = content.length
87-
revealedRef.current = content.length
88-
}
89113

90114
const hasBacklog = effectiveRevealed < content.length
91115

92116
useEffect(() => {
93-
if (!isStreaming) {
94-
revealedRef.current = contentRef.current.length
95-
setRevealed(contentRef.current.length)
96-
return
97-
}
117+
const run = () => {
118+
timeoutRef.current = null
119+
const text = contentRef.current
120+
const target = text.length
98121

99-
const tick = () => {
100-
const target = contentRef.current.length
101-
// Upstream sanitization can rewrite earlier text and shrink the string;
102-
// pull the cursor back to the new end so regrowth stays paced rather than
103-
// jumping past it.
104122
if (revealedRef.current > target) {
105123
revealedRef.current = target
106124
setRevealed(target)
107125
}
108126
const current = revealedRef.current
127+
if (current >= target) return
109128

110-
if (!streamingRef.current) {
111-
revealedRef.current = target
112-
setRevealed(target)
113-
frameRef.current = null
114-
return
115-
}
116-
if (current >= target) {
117-
frameRef.current = null
118-
return
119-
}
120-
121-
const backlog = target - current
122-
const step = Math.min(MAX_STEP, Math.max(MIN_STEP, Math.ceil(backlog / REVEAL_DIVISOR)))
123-
const next = current + step
129+
const next = nextIndex(text, current)
124130
revealedRef.current = next
125131
setRevealed(next)
126-
frameRef.current = window.requestAnimationFrame(tick)
132+
if (next < target) {
133+
timeoutRef.current = setTimeout(run, PACE_MS)
134+
}
127135
}
128136

129-
if (hasBacklog && frameRef.current === null) {
130-
frameRef.current = window.requestAnimationFrame(tick)
137+
if (hasBacklog && timeoutRef.current === null) {
138+
timeoutRef.current = setTimeout(run, PACE_MS)
131139
}
132140

133141
return () => {
134-
if (frameRef.current !== null) {
135-
window.cancelAnimationFrame(frameRef.current)
136-
frameRef.current = null
142+
if (timeoutRef.current !== null) {
143+
clearTimeout(timeoutRef.current)
144+
timeoutRef.current = null
137145
}
138146
}
139-
}, [isStreaming, hasBacklog])
147+
}, [hasBacklog])
140148

141-
// Content can shrink when upstream sanitization rewrites earlier text; never
142-
// hand back a slice index past the current end.
143149
if (effectiveRevealed >= content.length) return content
144150
return content.slice(0, effectiveRevealed)
145151
}

0 commit comments

Comments
 (0)