diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index 2048673ec50..0baeb6d70a1 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -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; 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 679c9a0c9c2..75a323d04b1 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 89bea30c4af..a605459da3f 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 ( ( 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 370326361ff..e30a40165c4 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 ? ( (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) }