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.
This commit is contained in:
Waleed
2026-06-10 13:10:40 -07:00
committed by GitHub
parent bc371b01c4
commit 9aa2a51655
7 changed files with 102 additions and 78 deletions
+2 -2
View File
@@ -21,8 +21,8 @@
--auth-primary-btn-hover-border: #e0e0e0;
--auth-primary-btn-hover-text: #000000;
/* z-index scale for layered UI
Popover must be above modal so dropdowns inside modals render correctly */
/* z-index scale. Transient poppers (menus, selects, popovers, tooltips, toasts)
sit above --z-modal so they stay clickable over the semi-transparent overlay. */
--z-dropdown: 100;
--z-modal: 200;
--z-popover: 300;
@@ -292,6 +292,11 @@ function ChatContentInner({
const displayContent = useMemo(() => sanitizeChatDisplayContent(content), [content])
const streamedContent = useSmoothText(displayContent, isStreaming)
const isRevealing = isStreaming || streamedContent.length < displayContent.length
const streamedThisSession = useRef(false)
if (isStreaming) streamedThisSession.current = true
const keepStreamingTree = isRevealing || streamedThisSession.current
useEffect(() => {
const handler = (e: Event) => {
@@ -308,8 +313,8 @@ function ChatContentInner({
}, [])
const parsed = useMemo(
() => parseSpecialTags(streamedContent, isStreaming),
[streamedContent, isStreaming]
() => parseSpecialTags(streamedContent, isRevealing),
[streamedContent, isRevealing]
)
const hasSpecialContent = parsed.hasPendingTag || parsed.segments.some((s) => s.type !== 'text')
@@ -365,9 +370,9 @@ function ChatContentInner({
className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}
>
<Streamdown
mode={isStreaming ? undefined : 'static'}
animated={isStreaming ? STREAM_ANIMATION : false}
isAnimating={isStreaming}
mode={keepStreamingTree ? undefined : 'static'}
animated={keepStreamingTree ? STREAM_ANIMATION : false}
isAnimating={isRevealing}
components={MARKDOWN_COMPONENTS}
>
{group.markdown}
@@ -383,7 +388,7 @@ function ChatContentInner({
/>
)
})}
{parsed.hasPendingTag && isStreaming && <PendingTagIndicator />}
{parsed.hasPendingTag && isRevealing && <PendingTagIndicator />}
</div>
)
}
@@ -391,9 +396,9 @@ function ChatContentInner({
return (
<div className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}>
<Streamdown
mode={isStreaming ? undefined : 'static'}
animated={isStreaming ? STREAM_ANIMATION : false}
isAnimating={isStreaming}
mode={keepStreamingTree ? undefined : 'static'}
animated={keepStreamingTree ? STREAM_ANIMATION : false}
isAnimating={isRevealing}
components={MARKDOWN_COMPONENTS}
>
{streamedContent}
@@ -223,6 +223,20 @@ export function MothershipChat({
}
return out
}, [messages])
const assistantTurnKeyByIndex = useMemo(() => {
const out: string[] = []
let lastUserId: string | undefined
let ordinal = 0
for (const [index, message] of messages.entries()) {
if (message.role === 'user') {
lastUserId = message.id
ordinal = 0
} else {
out[index] = lastUserId ? `assistant:${lastUserId}:${ordinal++}` : message.id
}
}
return out
}, [messages])
const initialScrollDoneRef = useRef(false)
const userInputRef = useRef<UserInputHandle>(null)
@@ -297,7 +311,7 @@ export function MothershipChat({
const isLast = index === messages.length - 1
return (
<AssistantMessageRow
key={msg.id}
key={assistantTurnKeyByIndex[index] ?? msg.id}
message={msg}
isStreaming={isStreamActive && isLast}
precedingUserContent={precedingUserContentByIndex[index]}
@@ -291,7 +291,6 @@ const ChipDropdown = forwardRef<HTMLButtonElement, ChipDropdownProps>(
align={align}
onOpenAutoFocus={searchable ? (event) => event.preventDefault() : undefined}
className={cn(
'z-[var(--z-popover)]',
matchTriggerWidth && 'w-[var(--radix-dropdown-menu-trigger-width)] max-w-none',
contentClassName
)}
@@ -238,7 +238,7 @@ export function ChipSelect({
align={align}
onOpenAutoFocus={searchable ? (e) => e.preventDefault() : undefined}
style={contentStyle}
className={cn('z-[var(--z-popover)] min-w-[160px]', contentClassName)}
className={cn('min-w-[160px]', contentClassName)}
>
{searchable ? (
<DropdownMenuSearchInput
@@ -30,7 +30,7 @@ const ANIMATION_CLASSES =
'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'
const CONTENT_BASE_CLASSES =
'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'
'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'
const DropdownMenu = DropdownMenuPrimitive.Root
+69 -63
View File
@@ -1,13 +1,42 @@
import { useEffect, useRef, useState } from 'react'
/**
* Per-frame reveal speed is proportional to how far behind the display is, so a
* large burst drains quickly while a trickle reveals gently. `target / DIVISOR`
* gives an ease-out feel; the clamps keep it from stalling or jumping.
* Paced reveal of a growing string, ported from opencode's `createPacedValue`
* (`packages/ui/src/components/message-part.tsx`). Instead of revealing a fixed
* number of characters per animation frame, it advances on a steady ~24ms timer
* in small tiered steps that SNAP to the next word/punctuation boundary — so
* text appears word-by-word at a calm, even cadence regardless of how bursty the
* upstream model deltas are. The boundary snapping is what keeps it from reading
* as "blocky": a reveal never stops mid-word.
*/
const REVEAL_DIVISOR = 6
const MIN_STEP = 1
const MAX_STEP = 400
const PACE_MS = 24
const SNAP = /[\s.,!?;:)\]]/
/**
* Characters to advance per tick as a function of how far the reveal is behind.
* Small backlogs trickle (2–8 chars); large backlogs accelerate but stay capped
* so a burst is spread over several ticks rather than dumped at once.
*/
function step(remaining: number): number {
if (remaining <= 12) return 2
if (remaining <= 48) return 4
if (remaining <= 96) return 8
return Math.min(24, Math.ceil(remaining / 8))
}
/**
* Advance from `start` by `step(...)`, then extend up to 8 more characters to
* land just past the next word/punctuation boundary so the reveal lands on a
* whole word rather than mid-token.
*/
function nextIndex(text: string, start: number): number {
const end = Math.min(text.length, start + step(text.length - start))
const max = Math.min(text.length, end + 8)
for (let i = end; i < max; i++) {
if (SNAP.test(text[i] ?? '')) return i + 1
}
return end
}
/**
* Content already longer than this when streaming begins is assumed to be
@@ -31,13 +60,25 @@ interface SmoothTextOptions {
}
/**
* Paces a growing string so it reveals at a steady cadence regardless of how
* bursty the upstream stream is — the client-side analogue of the AI SDK's
* `smoothStream`. Returns the portion of `content` that should be displayed now.
* Paces a growing string so it reveals word-by-word at a steady cadence
* regardless of how bursty the upstream stream is — a React port of opencode's
* paced text rendering. Returns the portion of `content` that should be
* displayed now.
*
* While `isStreaming` is false the full string is returned unchanged (history
* and completed turns never animate). When streaming ends mid-reveal the
* remaining tail is shown immediately so nothing is left hidden.
* Content that is already complete at mount (history, or a resume past
* {@link RESUME_SKIP_THRESHOLD}) is returned in full and never animates. When a
* live stream ends mid-reveal the remaining tail keeps draining at the paced
* cadence rather than snapping — so the reveal stays smooth right to the end and
* the caller can hold its streaming render until `useSmoothText` reports the
* full string, avoiding a flash on the streaming→static handoff.
*
* @remarks
* The reveal loop keys on `hasBacklog` rather than `content` so a new chunk on
* every render does not re-subscribe the timer (and trip React's
* max-update-depth guard); the running tick reads the latest value from a ref.
* If upstream sanitization rewrites earlier text and shrinks the string, the
* cursor is pulled back to the new end so regrowth stays paced instead of
* jumping past it.
*/
export function useSmoothText(
content: string,
@@ -51,15 +92,10 @@ export function useSmoothText(
)
const contentRef = useRef(content)
const streamingRef = useRef(isStreaming)
const revealedRef = useRef(revealed)
const frameRef = useRef<number | null>(null)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const prevContentRef = useRef(content)
// A non-append rewrite (e.g. a patch replacing earlier text) must be shown in
// full at once — re-revealing a prefix of rewritten content would look like
// the document is retyping itself. Adjust during render so the slice below
// never flashes a stale prefix.
let effectiveRevealed = revealed
if (
snapOnNonAppend &&
@@ -74,72 +110,42 @@ export function useSmoothText(
prevContentRef.current = content
contentRef.current = content
streamingRef.current = isStreaming
// Key the reveal loop to streaming + remaining backlog, NOT to `content`:
// `content` changes on every streamed chunk, and re-subscribing an rAF + setState
// loop on each change is the "a dependency changes on every render" pattern that
// trips React's max-update-depth guard. The running tick reads the latest content
// from `contentRef`, so new chunks are absorbed without per-chunk teardown;
// `hasBacklog` only flips when the reveal falls behind or catches up.
if (!isStreaming && effectiveRevealed !== content.length) {
effectiveRevealed = content.length
revealedRef.current = content.length
}
const hasBacklog = effectiveRevealed < content.length
useEffect(() => {
if (!isStreaming) {
revealedRef.current = contentRef.current.length
setRevealed(contentRef.current.length)
return
}
const run = () => {
timeoutRef.current = null
const text = contentRef.current
const target = text.length
const tick = () => {
const target = contentRef.current.length
// Upstream sanitization can rewrite earlier text and shrink the string;
// pull the cursor back to the new end so regrowth stays paced rather than
// jumping past it.
if (revealedRef.current > target) {
revealedRef.current = target
setRevealed(target)
}
const current = revealedRef.current
if (current >= target) return
if (!streamingRef.current) {
revealedRef.current = target
setRevealed(target)
frameRef.current = null
return
}
if (current >= target) {
frameRef.current = null
return
}
const backlog = target - current
const step = Math.min(MAX_STEP, Math.max(MIN_STEP, Math.ceil(backlog / REVEAL_DIVISOR)))
const next = current + step
const next = nextIndex(text, current)
revealedRef.current = next
setRevealed(next)
frameRef.current = window.requestAnimationFrame(tick)
if (next < target) {
timeoutRef.current = setTimeout(run, PACE_MS)
}
}
if (hasBacklog && frameRef.current === null) {
frameRef.current = window.requestAnimationFrame(tick)
if (hasBacklog && timeoutRef.current === null) {
timeoutRef.current = setTimeout(run, PACE_MS)
}
return () => {
if (frameRef.current !== null) {
window.cancelAnimationFrame(frameRef.current)
frameRef.current = null
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
}, [isStreaming, hasBacklog])
}, [hasBacklog])
// Content can shrink when upstream sanitization rewrites earlier text; never
// hand back a slice index past the current end.
if (effectiveRevealed >= content.length) return content
return content.slice(0, effectiveRevealed)
}