From c1d31616c418d7cf6dc2791869fb56ad0a854a70 Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 10 Jun 2026 12:30:24 -0700 Subject: [PATCH 1/3] fix(emcn): render dropdown menus above modals so in-modal dropdowns are clickable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/sim/app/_styles/globals.css | 9 +++++++-- .../emcn/components/chip-dropdown/chip-dropdown.tsx | 1 - .../emcn/components/chip-select/chip-select.tsx | 2 +- .../emcn/components/dropdown-menu/dropdown-menu.tsx | 2 +- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index 2048673ec5..5f7df776c5 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -21,8 +21,13 @@ --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 for layered UI. + --z-dropdown is the in-flow panel layer that sits BELOW modals (slide-over + panels, floating action bars, drag overlays). Transient poppers that can be + opened from inside a modal — menus, selects, popovers, tooltips, toasts — + must sit ABOVE --z-modal so they stay visible and clickable over the modal + surface (the modal overlay is semi-transparent, so a popper below it is + faintly visible but intercepts no clicks). */ --z-dropdown: 100; --z-modal: 200; --z-popover: 300; diff --git a/apps/sim/components/emcn/components/chip-dropdown/chip-dropdown.tsx b/apps/sim/components/emcn/components/chip-dropdown/chip-dropdown.tsx index 25ee72e809..fe73ee4c77 100644 --- a/apps/sim/components/emcn/components/chip-dropdown/chip-dropdown.tsx +++ b/apps/sim/components/emcn/components/chip-dropdown/chip-dropdown.tsx @@ -291,7 +291,6 @@ const ChipDropdown = forwardRef( 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 )} diff --git a/apps/sim/components/emcn/components/chip-select/chip-select.tsx b/apps/sim/components/emcn/components/chip-select/chip-select.tsx index 370326361f..e30a40165c 100644 --- a/apps/sim/components/emcn/components/chip-select/chip-select.tsx +++ b/apps/sim/components/emcn/components/chip-select/chip-select.tsx @@ -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 ? ( Date: Wed, 10 Jun 2026 12:53:25 -0700 Subject: [PATCH 2/3] 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. --- .../components/chat-content/chat-content.tsx | 23 +-- .../mothership-chat/mothership-chat.tsx | 16 ++- apps/sim/hooks/use-smooth-text.ts | 132 +++++++++--------- 3 files changed, 98 insertions(+), 73 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 679c9a0c9c..75a323d04b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -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')} > {group.markdown} @@ -383,7 +388,7 @@ function ChatContentInner({ /> ) })} - {parsed.hasPendingTag && isStreaming && } + {parsed.hasPendingTag && isRevealing && } ) } @@ -391,9 +396,9 @@ function ChatContentInner({ return (
:first-child]:mt-0 [&>:last-child]:mb-0')}> {streamedContent} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 89bea30c4a..a605459da3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -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(null) @@ -297,7 +311,7 @@ export function MothershipChat({ const isLast = index === messages.length - 1 return ( (null) + const timeoutRef = useRef | 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 && @@ -72,72 +108,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) } From 66de79f3770e0d105e5acbc7b1784ffda006499b Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 10 Jun 2026 13:03:05 -0700 Subject: [PATCH 3/3] 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. --- apps/sim/app/_styles/globals.css | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index 5f7df776c5..0baeb6d70a 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -21,13 +21,8 @@ --auth-primary-btn-hover-border: #e0e0e0; --auth-primary-btn-hover-text: #000000; - /* z-index scale for layered UI. - --z-dropdown is the in-flow panel layer that sits BELOW modals (slide-over - panels, floating action bars, drag overlays). Transient poppers that can be - opened from inside a modal — menus, selects, popovers, tooltips, toasts — - must sit ABOVE --z-modal so they stay visible and clickable over the modal - surface (the modal overlay is semi-transparent, so a popper below it is - faintly visible but intercepts no clicks). */ + /* 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;