From 70f46147df2f293478387635e27cfe81137528cd Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 12 Jun 2026 18:19:14 -0700 Subject: [PATCH 1/4] perf(mothership): virtualize chat transcript and isolate input from stream re-renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long chats rendered every message into the DOM with no windowing — a custom rAF "progressive list" only smeared the mount cost across frames without capping it. At ~1000 messages this was 52k DOM nodes and a 21s main-thread block on open, and the input toolbar re-rendered on every streamed token. - Virtualize the message list with @tanstack/react-virtual using dynamic measureElement, stable per-row keys, and a tuned size estimate. Only the visible window mounts, so load cost is now flat regardless of transcript length. Remove the now-redundant useProgressiveList hook. - Memoize UserInput and stabilize its callbacks (useCallback in MothershipChat and home) so streaming ticks no longer re-render the entire input toolbar. - Keep the existing useAutoScroll for streaming stick-to-bottom (it reads the virtualizer's real scrollHeight) and add a per-chat scrollToIndex for initial positioning before paint. Measured on a cloned 1032-message chat: time-to-rendered 26.3s -> 1.7s, main-thread blocked 21.4s -> 0.8s, DOM nodes 52k -> 1.4k, typing-while- streaming p-max 104ms -> 26ms. Adds scripts/perf/ harness used to validate. --- .../mothership-chat/mothership-chat.tsx | 196 +++++++---- .../home/components/user-input/user-input.tsx | 10 +- .../app/workspace/[workspaceId]/home/home.tsx | 43 ++- apps/sim/hooks/use-progressive-list.ts | 133 -------- scripts/perf/README.md | 49 +++ scripts/perf/chat-load-perf.mjs | 311 ++++++++++++++++++ scripts/perf/seed-chat-scale.mjs | 75 +++++ scripts/perf/stream-validate.mjs | 130 ++++++++ 8 files changed, 720 insertions(+), 227 deletions(-) delete mode 100644 apps/sim/hooks/use-progressive-list.ts create mode 100644 scripts/perf/README.md create mode 100644 scripts/perf/chat-load-perf.mjs create mode 100644 scripts/perf/seed-chat-scale.mjs create mode 100644 scripts/perf/stream-validate.mjs 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 6bd66067600..7a7a4184e5b 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 @@ -1,6 +1,7 @@ 'use client' import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' import { cn } from '@/lib/core/utils/cn' import { MessageActions } from '@/app/workspace/[workspaceId]/components' import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments' @@ -26,7 +27,6 @@ import type { QueuedMessage, } from '@/app/workspace/[workspaceId]/home/types' import { useAutoScroll } from '@/hooks/use-auto-scroll' -import { useProgressiveList } from '@/hooks/use-progressive-list' import type { ChatContext } from '@/stores/panel' import { MothershipChatSkeleton } from './components/mothership-chat-skeleton' @@ -61,12 +61,31 @@ interface MothershipChatProps { className?: string } +/** + * Estimated row heights seed the virtualizer before each row is measured. They + * only affect the scrollbar thumb on not-yet-rendered rows, so an approximate + * value is fine — every visible row is measured precisely via `measureElement`. + * Tuned to the blended average of short user rows and taller assistant rows so + * the scrollbar barely drifts as off-screen rows resolve. + */ +const ESTIMATED_ROW_HEIGHT = { + 'mothership-view': 200, + 'copilot-view': 130, +} as const + +/** + * Rows render farther beyond the viewport edges than the default so fast scroll + * and the streaming tail stay painted without a blank flash before measurement. + */ +const OVERSCAN = 6 + const LAYOUT_STYLES = { 'mothership-view': { scrollContainer: 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable_both-edges]', - content: 'mx-auto max-w-[48rem] space-y-6', - userRow: 'flex flex-col items-end gap-[6px] pt-3', + sizer: 'relative mx-auto w-full max-w-[48rem]', + rowGap: 'pb-6', + userRow: 'flex flex-col items-end gap-[6px]', attachmentWidth: 'max-w-[70%]', userBubble: 'max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2', assistantRow: 'group/msg', @@ -75,8 +94,9 @@ const LAYOUT_STYLES = { }, 'copilot-view': { scrollContainer: 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4', - content: 'space-y-4', - userRow: 'flex flex-col items-end gap-[6px] pt-2', + sizer: 'relative w-full', + rowGap: 'pb-4', + userRow: 'flex flex-col items-end gap-[6px]', attachmentWidth: 'max-w-[85%]', userBubble: 'max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2', assistantRow: 'group/msg', @@ -201,24 +221,26 @@ export function MothershipChat({ }: MothershipChatProps) { const styles = LAYOUT_STYLES[layout] const isStreamActive = isSending || isReconnecting - const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isStreamActive, { - scrollOnMount: true, - }) + const scrollElementRef = useRef(null) + const { ref: autoScrollRef } = useAutoScroll(isStreamActive) + const setScrollElement = useCallback( + (el: HTMLDivElement | null) => { + scrollElementRef.current = el + autoScrollRef(el) + }, + [autoScrollRef] + ) + const hasMessages = messages.length > 0 - const stagingKey = chatId ?? 'pending-chat' - const { staged: stagedMessages, isStaging } = useProgressiveList(messages, stagingKey) - const stagedMessageCount = stagedMessages.length - const stagedOffset = messages.length - stagedMessages.length - const precedingUserContentByIndex = useMemo(() => { - const out: Array = [] - let lastUserContent: string | undefined - for (const [index, message] of messages.entries()) { - out[index] = lastUserContent - if (message.role === 'user') lastUserContent = message.content - } - return out - }, [messages]) - const assistantTurnKeyByIndex = useMemo(() => { + + /** + * Stable per-row identity for virtualizer measurement caching and React + * reconciliation. User rows key on their message id; assistant rows key on + * their turn position (`assistant::`) so a streaming + * placeholder keeps the same element — and its smooth-text state — when the + * persisted message arrives with a new id. + */ + const rowKeyByIndex = useMemo(() => { const out: string[] = [] let lastUserId: string | undefined let ordinal = 0 @@ -226,14 +248,38 @@ export function MothershipChat({ if (message.role === 'user') { lastUserId = message.id ordinal = 0 + out[index] = message.id } else { out[index] = lastUserId ? `assistant:${lastUserId}:${ordinal++}` : message.id } } return out }, [messages]) - const initialScrollDoneRef = useRef(false) + + const precedingUserContentByIndex = useMemo(() => { + const out: Array = [] + let lastUserContent: string | undefined + for (const [index, message] of messages.entries()) { + out[index] = lastUserContent + if (message.role === 'user') lastUserContent = message.content + } + return out + }, [messages]) + + const virtualizer = useVirtualizer({ + count: messages.length, + getScrollElement: () => scrollElementRef.current, + estimateSize: () => ESTIMATED_ROW_HEIGHT[layout], + overscan: OVERSCAN, + getItemKey: (index) => rowKeyByIndex[index] ?? index, + }) + + const scrolledChatRef = useRef(undefined) const userInputRef = useRef(null) + const messageQueueRef = useRef(messageQueue) + useEffect(() => { + messageQueueRef.current = messageQueue + }, [messageQueue]) const onSubmitRef = useRef(onSubmit) useEffect(() => { @@ -243,37 +289,40 @@ export function MothershipChat({ onSubmitRef.current(id) }, []) - function handleSendQueuedHead() { - const topMessage = messageQueue[0] + const handleSendQueuedHead = useCallback(() => { + const topMessage = messageQueueRef.current[0] if (!topMessage) return void onSendQueuedMessage(topMessage.id) - } + }, [onSendQueuedMessage]) - function handleEditQueued(id: string) { - const msg = onEditQueuedMessage(id) - if (msg) userInputRef.current?.loadQueuedMessage(msg) - } + const handleEditQueued = useCallback( + (id: string) => { + const msg = onEditQueuedMessage(id) + if (msg) userInputRef.current?.loadQueuedMessage(msg) + }, + [onEditQueuedMessage] + ) - function handleEditQueuedTail() { - const tail = messageQueue[messageQueue.length - 1] + const handleEditQueuedTail = useCallback(() => { + const tail = messageQueueRef.current[messageQueueRef.current.length - 1] if (!tail) return handleEditQueued(tail.id) - } + }, [handleEditQueued]) + /** + * Land at the most recent message once per chat — on open and when switching + * chats (keyed on `chatId`, so it re-fires even between chats of equal length). + * Runs before paint so a long transcript never flashes at the top. Subsequent + * growth within the same chat is handled by {@link useAutoScroll}'s streaming + * sticky-scroll, not here. + */ useLayoutEffect(() => { - if (!hasMessages) { - initialScrollDoneRef.current = false - return - } - if (initialScrollDoneRef.current || initialScrollBlocked) return - initialScrollDoneRef.current = true - scrollToBottom() - }, [hasMessages, initialScrollBlocked, scrollToBottom]) + if (!hasMessages || initialScrollBlocked || scrolledChatRef.current === chatId) return + scrolledChatRef.current = chatId + virtualizer.scrollToIndex(messages.length - 1, { align: 'end' }) + }, [chatId, hasMessages, initialScrollBlocked, messages.length, virtualizer]) - useLayoutEffect(() => { - if (!isStaging || initialScrollBlocked || !initialScrollDoneRef.current) return - scrollToBottom() - }, [isStaging, stagedMessageCount, initialScrollBlocked, scrollToBottom]) + const virtualItems = virtualizer.getVirtualItems() return (
-
+
{isLoading && !hasMessages ? ( ) : ( -
- {stagedMessages.map((msg, localIndex) => { - const index = stagedOffset + localIndex - if (msg.role === 'user') { - return ( - - ) - } - +
+ {virtualItems.map((virtualItem) => { + const index = virtualItem.index + const msg = messages[index] const isLast = index === messages.length - 1 return ( - +
+ {msg.role === 'user' ? ( + + ) : ( + + )} +
) })}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index aaaa2a0659d..b946d4f5fb2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -3,6 +3,7 @@ import type React from 'react' import { forwardRef, + memo, useCallback, useEffect, useImperativeHandle, @@ -145,7 +146,7 @@ export interface UserInputHandle { populatePrompt: (text: string) => void } -export const UserInput = forwardRef(function UserInput( +const UserInputImpl = forwardRef(function UserInput( { defaultValue = '', draftScopeKey, @@ -1445,3 +1446,10 @@ export const UserInput = forwardRef(function Us
) }) + +/** + * Memoized so streaming ticks in the parent transcript — which re-render + * {@link MothershipChat} on every chunk — do not re-render the entire input + * toolbar. Relies on callers passing stable callbacks (see `MothershipChat`). + */ +export const UserInput = memo(UserInputImpl) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 11bf7dec41b..b22b783d7e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -233,36 +233,35 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom } }, [resources, collapseResource]) - function handleStopGeneration() { + const handleStopGeneration = useCallback(() => { captureEvent(posthogRef.current, 'task_generation_aborted', { workspace_id: workspaceId, view: 'mothership', request_id: getCurrentRequestId(), }) void stopGeneration().catch(() => {}) - } - - function handleSubmit( - text: string, - fileAttachments?: FileAttachmentForApi[], - contexts?: ChatContext[] - ) { - const trimmed = text.trim() - if (!trimmed && !(fileAttachments && fileAttachments.length > 0)) return - - captureEvent(posthogRef.current, 'task_message_sent', { - workspace_id: workspaceId, - has_attachments: !!(fileAttachments && fileAttachments.length > 0), - has_contexts: !!(contexts && contexts.length > 0), - is_new_task: !chatId, - }) + }, [workspaceId, getCurrentRequestId, stopGeneration]) + + const handleSubmit = useCallback( + (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { + const trimmed = text.trim() + if (!trimmed && !(fileAttachments && fileAttachments.length > 0)) return + + captureEvent(posthogRef.current, 'task_message_sent', { + workspace_id: workspaceId, + has_attachments: !!(fileAttachments && fileAttachments.length > 0), + has_contexts: !!(contexts && contexts.length > 0), + is_new_task: !chatId, + }) - if (initialViewInputRef.current) { - setIsInputEntering(true) - } + if (initialViewInputRef.current) { + setIsInputEntering(true) + } - sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, contexts) - } + sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, contexts) + }, + [workspaceId, chatId, sendMessage] + ) useEffect(() => { const handler = (e: Event) => { diff --git a/apps/sim/hooks/use-progressive-list.ts b/apps/sim/hooks/use-progressive-list.ts deleted file mode 100644 index bf60f4d67b0..00000000000 --- a/apps/sim/hooks/use-progressive-list.ts +++ /dev/null @@ -1,133 +0,0 @@ -'use client' - -import { useEffect, useLayoutEffect, useRef, useState } from 'react' - -interface ProgressiveListOptions { - /** Number of items to render in the initial batch (most recent items) */ - initialBatch?: number - /** Number of items to add per animation frame */ - batchSize?: number -} - -const DEFAULTS = { - initialBatch: 10, - batchSize: 5, -} satisfies Required - -interface ProgressiveListState { - key: string - count: number - caughtUp: boolean -} - -function createInitialState( - key: string, - itemCount: number, - initialBatch: number -): ProgressiveListState { - const count = Math.min(itemCount, initialBatch) - return { - key, - count, - caughtUp: itemCount > 0 && count >= itemCount, - } -} - -/** - * Progressively renders a list of items so that first paint is fast. - * - * On mount (or when `key` changes), only the most recent `initialBatch` - * items are rendered. The rest are added in `batchSize` increments via - * `requestAnimationFrame`. - * - * @param items Full list of items to render. - * @param key A session/conversation identifier. When it changes, - * staging restarts for the new list. - * @param options Tuning knobs for batch sizes. - * @returns The currently staged (visible) subset of items. - */ -export function useProgressiveList( - items: T[], - key: string, - options?: ProgressiveListOptions -): { staged: T[]; isStaging: boolean } { - const initialBatch = Math.max(0, options?.initialBatch ?? DEFAULTS.initialBatch) - const batchSize = Math.max(1, options?.batchSize ?? DEFAULTS.batchSize) - const [state, setState] = useState(() => createInitialState(key, items.length, initialBatch)) - const latestItemCountRef = useRef(items.length) - - useLayoutEffect(() => { - latestItemCountRef.current = items.length - }, [items.length]) - - const renderState = - state.key === key && (state.count > 0 || items.length === 0 || state.caughtUp) - ? state - : createInitialState(key, items.length, initialBatch) - - useEffect(() => { - setState((prev) => { - if (prev.key !== key) { - return createInitialState(key, items.length, initialBatch) - } - - if (items.length === 0) { - if (prev.count === 0 && !prev.caughtUp) { - return prev - } - return { key, count: 0, caughtUp: false } - } - - if (prev.caughtUp) { - if (prev.count === items.length) { - return prev - } - return { key, count: items.length, caughtUp: true } - } - - const minimumCount = Math.min(items.length, initialBatch) - if (prev.count >= minimumCount && prev.count <= items.length) { - return prev - } - - const count = Math.min(items.length, Math.max(prev.count, minimumCount)) - return { - key, - count, - caughtUp: count >= items.length, - } - }) - }, [key, items.length, initialBatch]) - - useEffect(() => { - if (state.key !== key || state.caughtUp || state.count >= items.length) { - return - } - - const frame = requestAnimationFrame(() => { - setState((prev) => { - if (prev.key !== key || prev.caughtUp) { - return prev - } - - const itemCount = latestItemCountRef.current - const count = Math.min(itemCount, prev.count + batchSize) - return { - key, - count, - caughtUp: count >= itemCount, - } - }) - }) - - return () => cancelAnimationFrame(frame) - }, [state.key, state.count, state.caughtUp, key, items.length, batchSize]) - - const effectiveCount = renderState.caughtUp - ? items.length - : Math.min(renderState.count, items.length) - const staged = items.slice(Math.max(0, items.length - effectiveCount)) - const isStaging = effectiveCount < items.length - - return { staged, isStaging } -} diff --git a/scripts/perf/README.md b/scripts/perf/README.md new file mode 100644 index 00000000000..c5ca6fc83cb --- /dev/null +++ b/scripts/perf/README.md @@ -0,0 +1,49 @@ +# Chat performance harness + +Headless-Chromium tooling for measuring and validating Mothership chat +performance against a local dev server. Built to investigate (and prove the fix +for) transcript slowdown in long chats. + +All scripts authenticate by minting a Better Auth session cookie from a live +`session` row in the local DB + `BETTER_AUTH_SECRET` — no login flow needed. Run +them from the repo root after `bun install` (Playwright is a dependency). + +## chat-load-perf.mjs + +Loads a chat and reports: chat GET API time/size, time-to-first-row, +time-to-all-rows, DOM node count, JS heap, main-thread long tasks, and +per-keystroke input latency. + +```bash +node scripts/perf/chat-load-perf.mjs --chat --workspace \ + [--base http://localhost:3000] [--runs 3] [--react-scan] [--send] +``` + +- `--react-scan` injects [react-scan](https://github.com/aidenybai/react-scan) + into the test browser and prints per-component render counts/self-time for the + load, idle-typing, and streaming phases. Requires the bundle at + `/tmp/react-scan-auto.global.js` (`curl -sL https://unpkg.com/react-scan/dist/auto.global.js -o /tmp/react-scan-auto.global.js`). +- `--send` posts a real message and measures typing latency while the assistant + streams. + +## seed-chat-scale.mjs + +Clones an existing chat's messages cyclically into new `PERF n=` chats so +load can be measured across transcript sizes. Rewrites `message_id` and +`content.id` (the client's React key) per copy. + +```bash +node scripts/perf/seed-chat-scale.mjs --source --sizes 32,258,516,1032 +# cleanup: delete from copilot_chats where title like 'PERF n=%'; +``` + +## stream-validate.mjs + +Opens a fresh chat, sends a prompt, and asserts the streaming reply grows +monotonically while the container stays pinned to the bottom — the two behaviors +most at risk from virtualizing the message list. Requires a reachable copilot +agent backend (won't produce a stream if the agent is unreachable). + +```bash +node scripts/perf/stream-validate.mjs --workspace [--base http://localhost:3000] +``` diff --git a/scripts/perf/chat-load-perf.mjs b/scripts/perf/chat-load-perf.mjs new file mode 100644 index 00000000000..4167f195d43 --- /dev/null +++ b/scripts/perf/chat-load-perf.mjs @@ -0,0 +1,311 @@ +/** + * Chat loading performance harness. + * + * Opens a Mothership chat in headless Chromium (authenticated via a Better Auth + * session cookie minted from the local DB) and measures where time goes: + * - API: GET /api/mothership/chats/ duration + * - firstRowMs: time from navigation to first message row painted + * - allRowsMs: time until the progressive list finishes (row count stable 1.5s) + * - domNodes / heapMB after the transcript is fully rendered + * - long tasks (main-thread blocks > 50ms) during load + * - typing frame stats: rAF frame durations while typing into the chat input + * + * Usage: + * node scripts/perf/chat-load-perf.mjs --chat --workspace \ + * [--base http://localhost:3000] [--email waleed@sim.ai] [--runs 3] [--headed] + */ +import { execFileSync } from 'node:child_process' +import { createHmac } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..') + +function arg(name, fallback) { + const i = process.argv.indexOf(`--${name}`) + return i >= 0 ? process.argv[i + 1] : fallback +} + +const CHAT_ID = arg('chat') +const WORKSPACE_ID = arg('workspace') +const BASE = arg('base', 'http://localhost:3000') +const EMAIL = arg('email', 'waleed@sim.ai') +const RUNS = Number(arg('runs', '3')) +const HEADED = process.argv.includes('--headed') +const REACT_SCAN = process.argv.includes('--react-scan') +const SEND = process.argv.includes('--send') +const REACT_SCAN_BUNDLE = '/tmp/react-scan-auto.global.js' + +if (!CHAT_ID || !WORKSPACE_ID) { + console.error('Usage: node scripts/perf/chat-load-perf.mjs --chat --workspace ') + process.exit(1) +} + +function readEnv(key) { + const env = readFileSync(resolve(ROOT, 'apps/sim/.env'), 'utf8') + const m = env.match(new RegExp(`^${key}="?([^"\n]+)"?$`, 'm')) + if (!m) throw new Error(`${key} not found in apps/sim/.env`) + return m[1] +} + +/** Mint a signed Better Auth session cookie from a live session row in the local DB. */ +function mintSessionCookie() { + const dbUrl = readEnv('DATABASE_URL') + const secret = readEnv('BETTER_AUTH_SECRET') + const token = execFileSync( + 'psql', + [dbUrl, '-At', '-c', + `select token from session where user_id = (select id from "user" where email = '${EMAIL}') and expires_at > now() order by expires_at desc limit 1`], + { encoding: 'utf8' } + ).trim() + if (!token) throw new Error(`No live session for ${EMAIL} in local DB — log in once at ${BASE} first`) + const signature = createHmac('sha256', secret).update(token).digest('base64') + return { + name: 'better-auth.session_token', + value: encodeURIComponent(`${token}.${signature}`), + domain: 'localhost', + path: '/', + httpOnly: true, + sameSite: 'Lax', + } +} + +/** Injected at document start: watches message rows, long tasks, and DOM size. */ +const INIT_SCRIPT = `(() => { + const perf = { firstRowAt: null, stableAt: null, rowCount: 0, domNodes: null, heapMB: null, + longTasks: 0, longTaskMs: 0, maxLongTaskMs: 0, done: false } + window.__chatPerf = perf + try { + new PerformanceObserver((list) => { + for (const e of list.getEntries()) { + perf.longTasks++ + perf.longTaskMs += e.duration + if (e.duration > perf.maxLongTaskMs) perf.maxLongTaskMs = e.duration + } + }).observe({ type: 'longtask', buffered: true }) + } catch {} + let lastCount = 0 + let lastChange = performance.now() + const tick = () => { + const rows = document.querySelectorAll('[class~="group/msg"]').length + if (rows > 0 && perf.firstRowAt == null) perf.firstRowAt = performance.now() + if (rows !== lastCount) { lastCount = rows; lastChange = performance.now(); perf.rowCount = rows } + if (rows > 0 && performance.now() - lastChange > 1500 && !perf.done) { + perf.stableAt = lastChange + perf.domNodes = document.getElementsByTagName('*').length + perf.heapMB = performance.memory ? Math.round(performance.memory.usedJSHeapSize / 1048576) : null + perf.done = true + return + } + setTimeout(tick, 50) + } + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', tick) + else tick() +})()` + +/** Aggregates react-scan onRender callbacks into per-component totals on window.__reactScanAgg. */ +const REACT_SCAN_CONFIG = `(() => { + if (typeof window.reactScan !== 'function') return + window.__reactScanAgg = Object.create(null) + window.reactScan({ + showToolbar: false, + log: false, + showFPS: false, + showNotificationCount: false, + trackUnnecessaryRenders: true, + onRender: (fiber, renders) => { + const t = fiber && fiber.type + const name = (t && (t.displayName || t.name)) || (typeof t === 'string' ? t : 'Unknown') + const agg = window.__reactScanAgg[name] || (window.__reactScanAgg[name] = { renders: 0, time: 0, unnecessary: 0 }) + for (const r of renders) { + agg.renders++ + agg.time += r.time || 0 + if (r.unnecessary) agg.unnecessary++ + } + }, + }) +})()` + +async function snapshotReactScan(page, top = 12) { + return page.evaluate((topN) => { + const agg = window.__reactScanAgg || {} + const rows = Object.entries(agg) + .map(([name, a]) => ({ name, renders: a.renders, timeMs: Math.round(a.time * 10) / 10, unnecessary: a.unnecessary })) + .sort((a, b) => b.timeMs - a.timeMs) + .slice(0, topN) + window.__reactScanAgg = Object.create(null) + return rows + }, top) +} + +async function measureRun(context, url, { screenshotPath } = {}) { + const page = await context.newPage() + const consoleErrors = [] + page.on('console', (msg) => { if (msg.type() === 'error') consoleErrors.push(msg.text()) }) + + let apiMs = null + let apiBytes = null + page.on('response', async (res) => { + if (res.url().includes(`/api/mothership/chats/${CHAT_ID}`) && res.request().method() === 'GET') { + try { apiBytes = (await res.body()).length } catch {} + } + }) + page.on('requestfinished', (req) => { + if (req.url().includes(`/api/mothership/chats/${CHAT_ID}`) && req.method() === 'GET') { + apiMs = Math.round(req.timing().responseEnd) + } + }) + + await page.goto(url, { waitUntil: 'commit', timeout: 180_000 }) + await page.waitForFunction(() => window.__chatPerf?.done === true, null, { timeout: 90_000 }) + const m = await page.evaluate(() => window.__chatPerf) + + if (page.url().includes('/login')) throw new Error('Redirected to /login — session cookie rejected') + if (screenshotPath) await page.screenshot({ path: screenshotPath, fullPage: false }) + + const loadRenders = REACT_SCAN ? await snapshotReactScan(page) : null + + const textarea = page.locator('textarea').first() + let typing = null + try { + await textarea.click({ timeout: 5000 }) + await page.evaluate(() => { + const lat = [] + window.__keyLat = lat + document.addEventListener('keydown', () => { + const t = performance.now() + requestAnimationFrame(() => lat.push(performance.now() - t)) + }, { capture: true }) + }) + const text = 'why is the chat so slow when the transcript is long? '.repeat(5) + await page.keyboard.type(text, { delay: 10 }) + typing = await page.evaluate(() => { + const lat = (window.__keyLat || []).slice().sort((a, b) => a - b) + if (!lat.length) return null + return { + keys: lat.length, + avgKeyMs: Math.round(lat.reduce((s, d) => s + d, 0) / lat.length * 10) / 10, + p95KeyMs: Math.round(lat[Math.floor(lat.length * 0.95)] * 10) / 10, + maxKeyMs: Math.round(lat[lat.length - 1] * 10) / 10, + } + }) + } catch { /* typing probe is best-effort */ } + + const typingRenders = REACT_SCAN ? await snapshotReactScan(page) : null + + let streaming = null + let streamRenders = null + if (SEND) { + try { + await page.evaluate(() => { if (window.__keyLat) window.__keyLat.length = 0 }) + await page.keyboard.press('Enter') + await page.waitForTimeout(1500) + const probeUntil = Date.now() + 15_000 + while (Date.now() < probeUntil) { + await page.keyboard.type('still typing while the assistant streams ', { delay: 30 }) + } + streaming = await page.evaluate(() => { + const lat = (window.__keyLat || []).slice().sort((a, b) => a - b) + if (!lat.length) return null + return { + keys: lat.length, + avgKeyMs: Math.round(lat.reduce((s, d) => s + d, 0) / lat.length * 10) / 10, + p95KeyMs: Math.round(lat[Math.floor(lat.length * 0.95)] * 10) / 10, + maxKeyMs: Math.round(lat[lat.length - 1] * 10) / 10, + } + }) + streamRenders = REACT_SCAN ? await snapshotReactScan(page) : null + await page.screenshot({ path: `/tmp/chat-perf-send-${CHAT_ID.slice(0, 8)}.png` }) + } catch (e) { + console.error(` send probe failed: ${e.message.split('\n')[0]}`) + } + } + + await page.close() + return { + loadRenders, + typingRenders, + streaming, + streamRenders, + apiMs, + apiKB: apiBytes != null ? Math.round(apiBytes / 1024) : null, + firstRowMs: m.firstRowAt != null ? Math.round(m.firstRowAt) : null, + allRowsMs: m.stableAt != null ? Math.round(m.stableAt) : null, + rowCount: m.rowCount, + domNodes: m.domNodes, + heapMB: m.heapMB, + longTasks: m.longTasks, + longTaskMs: Math.round(m.longTaskMs), + maxLongTaskMs: Math.round(m.maxLongTaskMs), + typing, + consoleErrors: consoleErrors.slice(0, 5), + } +} + +const cookie = mintSessionCookie() +const browser = await chromium.launch({ headless: !HEADED }) +const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }) +await context.addCookies([cookie]) +if (REACT_SCAN) { + await context.addInitScript(readFileSync(REACT_SCAN_BUNDLE, 'utf8')) + await context.addInitScript(REACT_SCAN_CONFIG) +} +await context.addInitScript(INIT_SCRIPT) + +const url = `${BASE}/workspace/${WORKSPACE_ID}/chat/${CHAT_ID}` +console.log(`Measuring ${url} (${RUNS} runs + warmup)`) + +try { + await measureRun(context, url) // warmup: Next.js dev compiles the route on first hit +} catch (e) { + console.error(`Warmup failed: ${e.message}`) + await browser.close() + process.exit(1) +} + +const results = [] +for (let i = 0; i < RUNS; i++) { + const screenshotPath = i === 0 ? `/tmp/chat-perf-${CHAT_ID.slice(0, 8)}.png` : undefined + const r = await measureRun(context, url, { screenshotPath }) + results.push(r) + console.log(`run ${i + 1}: api=${r.apiMs}ms (${r.apiKB}KB) firstRow=${r.firstRowMs}ms allRows=${r.allRowsMs}ms rows=${r.rowCount} dom=${r.domNodes} heap=${r.heapMB}MB longTasks=${r.longTasks}/${r.longTaskMs}ms(max ${r.maxLongTaskMs}ms) keystroke avg=${r.typing?.avgKeyMs}ms p95=${r.typing?.p95KeyMs}ms max=${r.typing?.maxKeyMs}ms (${r.typing?.keys} keys)`) + if (r.consoleErrors.length) console.log(` console errors: ${r.consoleErrors.join(' | ')}`) + if (r.loadRenders) { + console.log(' react-scan LOAD phase (top by self time):') + for (const c of r.loadRenders) console.log(` ${c.name}: ${c.renders} renders, ${c.timeMs}ms, ${c.unnecessary} unnecessary`) + console.log(' react-scan TYPING phase (top by self time):') + for (const c of r.typingRenders ?? []) console.log(` ${c.name}: ${c.renders} renders, ${c.timeMs}ms, ${c.unnecessary} unnecessary`) + } + if (r.streaming) { + console.log(` STREAMING keystroke: avg=${r.streaming.avgKeyMs}ms p95=${r.streaming.p95KeyMs}ms max=${r.streaming.maxKeyMs}ms (${r.streaming.keys} keys)`) + if (r.streamRenders) { + console.log(' react-scan STREAM phase (top by self time):') + for (const c of r.streamRenders) console.log(` ${c.name}: ${c.renders} renders, ${c.timeMs}ms, ${c.unnecessary} unnecessary`) + } + } +} + +const median = (key, sub) => { + const vals = results.map((r) => (sub ? r[key]?.[sub] : r[key])).filter((v) => v != null).sort((a, b) => a - b) + return vals.length ? vals[Math.floor(vals.length / 2)] : null +} + +console.log('\nmedian:', JSON.stringify({ + apiMs: median('apiMs'), + apiKB: median('apiKB'), + firstRowMs: median('firstRowMs'), + allRowsMs: median('allRowsMs'), + rowCount: median('rowCount'), + domNodes: median('domNodes'), + heapMB: median('heapMB'), + longTasks: median('longTasks'), + longTaskMs: median('longTaskMs'), + maxLongTaskMs: median('maxLongTaskMs'), + keystrokeAvgMs: median('typing', 'avgKeyMs'), + keystrokeP95Ms: median('typing', 'p95KeyMs'), + keystrokeMaxMs: median('typing', 'maxKeyMs'), +})) + +await browser.close() diff --git a/scripts/perf/seed-chat-scale.mjs b/scripts/perf/seed-chat-scale.mjs new file mode 100644 index 00000000000..a621ca2e883 --- /dev/null +++ b/scripts/perf/seed-chat-scale.mjs @@ -0,0 +1,75 @@ +/** + * Seeds synthetic copilot chats at arbitrary message counts by cycling the + * messages of an existing source chat. Each copy gets a fresh message_id + * (mirrored into content.id, which the client uses as the React key) and a + * monotonically increasing seq/created_at so ordering is preserved. + * + * Usage: + * node scripts/perf/seed-chat-scale.mjs --source [--sizes 32,258,516,1032] + * + * Prints one line per created chat: + * Cleanup: delete from copilot_chats where title like 'PERF n=%'; + */ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..') + +function arg(name, fallback) { + const i = process.argv.indexOf(`--${name}`) + return i >= 0 ? process.argv[i + 1] : fallback +} + +const SOURCE = arg('source') +const SIZES = arg('sizes', '32,258,516,1032').split(',').map(Number) +if (!SOURCE || SIZES.some(Number.isNaN)) { + console.error('Usage: node scripts/perf/seed-chat-scale.mjs --source [--sizes 32,258,516]') + process.exit(1) +} + +const env = readFileSync(resolve(ROOT, 'apps/sim/.env'), 'utf8') +const DB_URL = env.match(/^DATABASE_URL="?([^"\n]+)"?$/m)[1] + +const psql = (sql) => execFileSync('psql', [DB_URL, '-At', '-c', sql], { encoding: 'utf8' }).trim() + +for (const size of SIZES) { + const sql = ` + with new_chat as ( + insert into copilot_chats (id, user_id, workflow_id, title, model, workspace_id, type, resources, created_at, updated_at) + select gen_random_uuid(), user_id, null, 'PERF n=${size}', model, workspace_id, type, resources, now(), now() + from copilot_chats where id = '${SOURCE}' + returning id + ), + src as ( + select role, content, model, tokens_in, tokens_out, + row_number() over (order by seq asc nulls last, created_at asc, id asc) as rn, + count(*) over () as total + from copilot_messages + where chat_id = '${SOURCE}' and deleted_at is null + ), + expanded as ( + select s.*, g.copy_idx, (g.copy_idx * s.total + s.rn) as new_seq + from src s + cross join generate_series(0, ${size} / 1 / (select min(total) from src) + 1) as g(copy_idx) + ), + prepared as ( + select role, content, model, tokens_in, tokens_out, new_seq, + gen_random_uuid()::text as new_mid + from expanded + where new_seq <= ${size} + ) + insert into copilot_messages (id, chat_id, message_id, role, content, model, tokens_in, tokens_out, seq, created_at, updated_at) + select gen_random_uuid(), (select id from new_chat), new_mid, role, + jsonb_set(content, '{id}', to_jsonb(new_mid)), + model, tokens_in, tokens_out, new_seq, + now() - interval '1 day' + (new_seq * interval '1 second'), + now() - interval '1 day' + (new_seq * interval '1 second') + from prepared + returning chat_id` + const out = psql(sql).split('\n').filter(Boolean) + const chatId = out[0] + const count = psql(`select count(*) from copilot_messages where chat_id = '${chatId}'`) + console.log(`${size} ${chatId} (inserted ${count} messages)`) +} diff --git a/scripts/perf/stream-validate.mjs b/scripts/perf/stream-validate.mjs new file mode 100644 index 00000000000..1b287beb3e8 --- /dev/null +++ b/scripts/perf/stream-validate.mjs @@ -0,0 +1,130 @@ +/** + * Streaming correctness probe for the virtualized transcript. + * + * Opens a fresh chat, sends a prompt, and samples the scroll container while the + * assistant streams. Asserts the streaming reply grows monotonically AND the + * container stays pinned to the bottom (auto-scroll follows the growing tail) — + * the two behaviors most at risk from virtualizing the message list. + * + * Usage: + * node scripts/perf/stream-validate.mjs --workspace [--base http://localhost:3000] [--prompt "..."] + */ +import { execFileSync } from 'node:child_process' +import { createHmac } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..') + +function arg(name, fallback) { + const i = process.argv.indexOf(`--${name}`) + return i >= 0 ? process.argv[i + 1] : fallback +} + +const WORKSPACE_ID = arg('workspace') +const BASE = arg('base', 'http://localhost:3000') +const EMAIL = arg('email', 'waleed@sim.ai') +const PROMPT = arg( + 'prompt', + 'Write about 400 words explaining how database indexes work, with a few short bullet lists.' +) + +if (!WORKSPACE_ID) { + console.error('Usage: node scripts/perf/stream-validate.mjs --workspace ') + process.exit(1) +} + +function readEnv(key) { + const env = readFileSync(resolve(ROOT, 'apps/sim/.env'), 'utf8') + const m = env.match(new RegExp(`^${key}="?([^"\n]+)"?$`, 'm')) + if (!m) throw new Error(`${key} not found in apps/sim/.env`) + return m[1] +} + +function mintSessionCookie() { + const dbUrl = readEnv('DATABASE_URL') + const secret = readEnv('BETTER_AUTH_SECRET') + const token = execFileSync( + 'psql', + [dbUrl, '-At', '-c', + `select token from session where user_id = (select id from "user" where email = '${EMAIL}') and expires_at > now() order by expires_at desc limit 1`], + { encoding: 'utf8' } + ).trim() + if (!token) throw new Error(`No live session for ${EMAIL}`) + const signature = createHmac('sha256', secret).update(token).digest('base64') + return { + name: 'better-auth.session_token', + value: encodeURIComponent(`${token}.${signature}`), + domain: 'localhost', + path: '/', + httpOnly: true, + sameSite: 'Lax', + } +} + +const browser = await chromium.launch({ headless: true }) +const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }) +await context.addCookies([mintSessionCookie()]) +const page = await context.newPage() + +await page.goto(`${BASE}/workspace/${WORKSPACE_ID}/home`, { waitUntil: 'commit', timeout: 180_000 }) +const textarea = page.locator('textarea').first() +await textarea.waitFor({ state: 'visible', timeout: 60_000 }) +await textarea.click() +await page.keyboard.type(PROMPT, { delay: 5 }) +await page.keyboard.press('Enter') + +/** Sample the scroll container + streaming reply length every 250ms for ~20s. */ +const samples = await page.evaluate(async () => { + const out = [] + const scroller = () => + document.querySelector('[class*="overflow-y-auto"]') + const lastAssistantLen = () => { + const rows = document.querySelectorAll('[class~="group/msg"]') + const last = rows[rows.length - 1] + return last ? (last.textContent || '').length : 0 + } + for (let i = 0; i < 80; i++) { + const el = scroller() + if (el) { + out.push({ + t: i * 250, + len: lastAssistantLen(), + distanceFromBottom: Math.round(el.scrollHeight - el.scrollTop - el.clientHeight), + rows: document.querySelectorAll('[class~="group/msg"]').length, + }) + } + await new Promise((r) => setTimeout(r, 250)) + } + return out +}) + +await page.screenshot({ path: '/tmp/stream-validate.png' }) +await browser.close() + +const grew = samples.filter((s) => s.len > 0) +const maxLen = Math.max(0, ...samples.map((s) => s.len)) +const firstGrowth = grew.find((s) => s.len > 20) +const peakIdx = samples.findIndex((s) => s.len === maxLen) +// During active growth, the container should track the bottom (small distance). +const growthWindow = samples.filter((s, i) => i <= peakIdx && s.len > 20) +const pinnedDuringGrowth = growthWindow.filter((s) => s.distanceFromBottom <= 80).length +const pinnedRatio = growthWindow.length ? pinnedDuringGrowth / growthWindow.length : 0 +const monotonic = grew.every((s, i, a) => i === 0 || s.len >= a[i - 1].len - 5) + +console.log(JSON.stringify({ + streamed: maxLen > 40, + maxReplyChars: maxLen, + firstGrowthAtMs: firstGrowth?.t ?? null, + monotonicGrowth: monotonic, + pinnedDuringGrowthRatio: Math.round(pinnedRatio * 100) / 100, + maxDistanceDuringGrowth: growthWindow.length ? Math.max(...growthWindow.map((s) => s.distanceFromBottom)) : null, + finalRows: samples.at(-1)?.rows ?? 0, +}, null, 2)) + +if (maxLen <= 40) { + console.error('\n⚠️ No assistant stream detected — cannot validate streaming follow.') + process.exit(2) +} From 6d94ee8b22e72624b6f5b568f6595b714721bca0 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 12 Jun 2026 18:37:09 -0700 Subject: [PATCH 2/4] address review: pending-chat scroll, flash on tail unmount, empty-row gap, per-role estimate - Pending-chat initial scroll (Cursor, high): seed the scrolled-chat guard with a unique sentinel so a not-yet-persisted chat (undefined chatId) with messages still scrolls to bottom instead of being treated as already-scrolled. - Streaming-row flash (review of virtualization): pin the last row in the rendered window via rangeExtractor so scrolling it out of the overscan window and back mid-stream can't unmount/remount it and re-fire the reveal fade. - Empty assistant row gap (Greptile): move the row gap from the virtual-item wrapper into the row content so a null-rendering (finalised, empty) assistant turn collapses to zero height instead of leaving a pb-6 blank slot. - Per-role row-height estimate instead of a single blended constant, so the scrollbar drifts less as off-screen rows resolve. - Drop the scripts/perf harness from the PR. --- .../mothership-chat/mothership-chat.tsx | 69 +++- scripts/perf/README.md | 49 --- scripts/perf/chat-load-perf.mjs | 311 ------------------ scripts/perf/seed-chat-scale.mjs | 75 ----- scripts/perf/stream-validate.mjs | 130 -------- 5 files changed, 51 insertions(+), 583 deletions(-) delete mode 100644 scripts/perf/README.md delete mode 100644 scripts/perf/chat-load-perf.mjs delete mode 100644 scripts/perf/seed-chat-scale.mjs delete mode 100644 scripts/perf/stream-validate.mjs 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 7a7a4184e5b..a81795c9cfb 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 @@ -1,7 +1,7 @@ 'use client' import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' -import { useVirtualizer } from '@tanstack/react-virtual' +import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/react-virtual' import { cn } from '@/lib/core/utils/cn' import { MessageActions } from '@/app/workspace/[workspaceId]/components' import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments' @@ -62,15 +62,15 @@ interface MothershipChatProps { } /** - * Estimated row heights seed the virtualizer before each row is measured. They - * only affect the scrollbar thumb on not-yet-rendered rows, so an approximate - * value is fine — every visible row is measured precisely via `measureElement`. - * Tuned to the blended average of short user rows and taller assistant rows so - * the scrollbar barely drifts as off-screen rows resolve. + * Per-role row-height estimates seed the virtualizer before each row is measured. + * They only size the scrollbar for not-yet-rendered rows — every visible row is + * measured precisely via `measureElement` — so approximate values suffice. Split + * by role because user bubbles are short and assistant turns are tall; a single + * blended number would over/under-shoot both and drift the scrollbar more. */ -const ESTIMATED_ROW_HEIGHT = { - 'mothership-view': 200, - 'copilot-view': 130, +const ROW_HEIGHT_ESTIMATE = { + 'mothership-view': { user: 64, assistant: 280 }, + 'copilot-view': { user: 48, assistant: 180 }, } as const /** @@ -79,6 +79,14 @@ const ESTIMATED_ROW_HEIGHT = { */ const OVERSCAN = 6 +/** + * Initial-scroll sentinel. Distinct from every real `chatId` value — including + * `undefined` (a not-yet-persisted chat) — so the first scroll-to-bottom fires + * even before a chat has an id, instead of treating `undefined` as "already + * scrolled this chat". + */ +const UNSCROLLED = Symbol('unscrolled') + const LAYOUT_STYLES = { 'mothership-view': { scrollContainer: @@ -266,15 +274,38 @@ export function MothershipChat({ return out }, [messages]) + /** + * Always keep the last row in the rendered window. It is the live/streaming + * row; unmounting it (by scrolling far enough up that it leaves the overscan + * window) and remounting it mid-stream would reset its smooth-text reveal + * state and re-fire the fade-in animation — a visible flash. Pinning it costs + * one extra always-mounted row. + */ + const lastIndex = messages.length - 1 + const rangeExtractor = useCallback( + (range: Range) => { + const indexes = defaultRangeExtractor(range) + if (lastIndex >= 0 && !indexes.includes(lastIndex)) { + indexes.push(lastIndex) + } + return indexes + }, + [lastIndex] + ) + const virtualizer = useVirtualizer({ count: messages.length, getScrollElement: () => scrollElementRef.current, - estimateSize: () => ESTIMATED_ROW_HEIGHT[layout], + estimateSize: (index) => { + const estimate = ROW_HEIGHT_ESTIMATE[layout] + return messages[index]?.role === 'user' ? estimate.user : estimate.assistant + }, overscan: OVERSCAN, getItemKey: (index) => rowKeyByIndex[index] ?? index, + rangeExtractor, }) - const scrolledChatRef = useRef(undefined) + const scrolledChatRef = useRef(UNSCROLLED) const userInputRef = useRef(null) const messageQueueRef = useRef(messageQueue) useEffect(() => { @@ -311,7 +342,9 @@ export function MothershipChat({ /** * Land at the most recent message once per chat — on open and when switching - * chats (keyed on `chatId`, so it re-fires even between chats of equal length). + * chats. The ref tracks which `chatId` we last scrolled for (seeded with + * {@link UNSCROLLED} so a pending, id-less chat still scrolls on first mount), + * so it re-fires on every chat change, including between chats of equal length. * Runs before paint so a long transcript never flashes at the top. Subsequent * growth within the same chat is handled by {@link useAutoScroll}'s streaming * sticky-scroll, not here. @@ -319,8 +352,8 @@ export function MothershipChat({ useLayoutEffect(() => { if (!hasMessages || initialScrollBlocked || scrolledChatRef.current === chatId) return scrolledChatRef.current = chatId - virtualizer.scrollToIndex(messages.length - 1, { align: 'end' }) - }, [chatId, hasMessages, initialScrollBlocked, messages.length, virtualizer]) + virtualizer.scrollToIndex(lastIndex, { align: 'end' }) + }, [chatId, hasMessages, initialScrollBlocked, lastIndex, virtualizer]) const virtualItems = virtualizer.getVirtualItems() @@ -341,13 +374,13 @@ export function MothershipChat({ {virtualItems.map((virtualItem) => { const index = virtualItem.index const msg = messages[index] - const isLast = index === messages.length - 1 + const isLast = index === lastIndex return (
{msg.role === 'user' ? ( @@ -355,7 +388,7 @@ export function MothershipChat({ content={msg.content} contexts={msg.contexts} attachments={msg.attachments} - rowClassName={styles.userRow} + rowClassName={cn(styles.userRow, styles.rowGap)} bubbleClassName={styles.userBubble} attachmentWidthClassName={styles.attachmentWidth} /> @@ -364,7 +397,7 @@ export function MothershipChat({ message={msg} isStreaming={isStreamActive && isLast} precedingUserContent={precedingUserContentByIndex[index]} - rowClassName={styles.assistantRow} + rowClassName={cn(styles.assistantRow, styles.rowGap)} onOptionSelect={isLast ? stableOnOptionSelect : undefined} /> )} diff --git a/scripts/perf/README.md b/scripts/perf/README.md deleted file mode 100644 index c5ca6fc83cb..00000000000 --- a/scripts/perf/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Chat performance harness - -Headless-Chromium tooling for measuring and validating Mothership chat -performance against a local dev server. Built to investigate (and prove the fix -for) transcript slowdown in long chats. - -All scripts authenticate by minting a Better Auth session cookie from a live -`session` row in the local DB + `BETTER_AUTH_SECRET` — no login flow needed. Run -them from the repo root after `bun install` (Playwright is a dependency). - -## chat-load-perf.mjs - -Loads a chat and reports: chat GET API time/size, time-to-first-row, -time-to-all-rows, DOM node count, JS heap, main-thread long tasks, and -per-keystroke input latency. - -```bash -node scripts/perf/chat-load-perf.mjs --chat --workspace \ - [--base http://localhost:3000] [--runs 3] [--react-scan] [--send] -``` - -- `--react-scan` injects [react-scan](https://github.com/aidenybai/react-scan) - into the test browser and prints per-component render counts/self-time for the - load, idle-typing, and streaming phases. Requires the bundle at - `/tmp/react-scan-auto.global.js` (`curl -sL https://unpkg.com/react-scan/dist/auto.global.js -o /tmp/react-scan-auto.global.js`). -- `--send` posts a real message and measures typing latency while the assistant - streams. - -## seed-chat-scale.mjs - -Clones an existing chat's messages cyclically into new `PERF n=` chats so -load can be measured across transcript sizes. Rewrites `message_id` and -`content.id` (the client's React key) per copy. - -```bash -node scripts/perf/seed-chat-scale.mjs --source --sizes 32,258,516,1032 -# cleanup: delete from copilot_chats where title like 'PERF n=%'; -``` - -## stream-validate.mjs - -Opens a fresh chat, sends a prompt, and asserts the streaming reply grows -monotonically while the container stays pinned to the bottom — the two behaviors -most at risk from virtualizing the message list. Requires a reachable copilot -agent backend (won't produce a stream if the agent is unreachable). - -```bash -node scripts/perf/stream-validate.mjs --workspace [--base http://localhost:3000] -``` diff --git a/scripts/perf/chat-load-perf.mjs b/scripts/perf/chat-load-perf.mjs deleted file mode 100644 index 4167f195d43..00000000000 --- a/scripts/perf/chat-load-perf.mjs +++ /dev/null @@ -1,311 +0,0 @@ -/** - * Chat loading performance harness. - * - * Opens a Mothership chat in headless Chromium (authenticated via a Better Auth - * session cookie minted from the local DB) and measures where time goes: - * - API: GET /api/mothership/chats/ duration - * - firstRowMs: time from navigation to first message row painted - * - allRowsMs: time until the progressive list finishes (row count stable 1.5s) - * - domNodes / heapMB after the transcript is fully rendered - * - long tasks (main-thread blocks > 50ms) during load - * - typing frame stats: rAF frame durations while typing into the chat input - * - * Usage: - * node scripts/perf/chat-load-perf.mjs --chat --workspace \ - * [--base http://localhost:3000] [--email waleed@sim.ai] [--runs 3] [--headed] - */ -import { execFileSync } from 'node:child_process' -import { createHmac } from 'node:crypto' -import { readFileSync } from 'node:fs' -import { dirname, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { chromium } from 'playwright' - -const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..') - -function arg(name, fallback) { - const i = process.argv.indexOf(`--${name}`) - return i >= 0 ? process.argv[i + 1] : fallback -} - -const CHAT_ID = arg('chat') -const WORKSPACE_ID = arg('workspace') -const BASE = arg('base', 'http://localhost:3000') -const EMAIL = arg('email', 'waleed@sim.ai') -const RUNS = Number(arg('runs', '3')) -const HEADED = process.argv.includes('--headed') -const REACT_SCAN = process.argv.includes('--react-scan') -const SEND = process.argv.includes('--send') -const REACT_SCAN_BUNDLE = '/tmp/react-scan-auto.global.js' - -if (!CHAT_ID || !WORKSPACE_ID) { - console.error('Usage: node scripts/perf/chat-load-perf.mjs --chat --workspace ') - process.exit(1) -} - -function readEnv(key) { - const env = readFileSync(resolve(ROOT, 'apps/sim/.env'), 'utf8') - const m = env.match(new RegExp(`^${key}="?([^"\n]+)"?$`, 'm')) - if (!m) throw new Error(`${key} not found in apps/sim/.env`) - return m[1] -} - -/** Mint a signed Better Auth session cookie from a live session row in the local DB. */ -function mintSessionCookie() { - const dbUrl = readEnv('DATABASE_URL') - const secret = readEnv('BETTER_AUTH_SECRET') - const token = execFileSync( - 'psql', - [dbUrl, '-At', '-c', - `select token from session where user_id = (select id from "user" where email = '${EMAIL}') and expires_at > now() order by expires_at desc limit 1`], - { encoding: 'utf8' } - ).trim() - if (!token) throw new Error(`No live session for ${EMAIL} in local DB — log in once at ${BASE} first`) - const signature = createHmac('sha256', secret).update(token).digest('base64') - return { - name: 'better-auth.session_token', - value: encodeURIComponent(`${token}.${signature}`), - domain: 'localhost', - path: '/', - httpOnly: true, - sameSite: 'Lax', - } -} - -/** Injected at document start: watches message rows, long tasks, and DOM size. */ -const INIT_SCRIPT = `(() => { - const perf = { firstRowAt: null, stableAt: null, rowCount: 0, domNodes: null, heapMB: null, - longTasks: 0, longTaskMs: 0, maxLongTaskMs: 0, done: false } - window.__chatPerf = perf - try { - new PerformanceObserver((list) => { - for (const e of list.getEntries()) { - perf.longTasks++ - perf.longTaskMs += e.duration - if (e.duration > perf.maxLongTaskMs) perf.maxLongTaskMs = e.duration - } - }).observe({ type: 'longtask', buffered: true }) - } catch {} - let lastCount = 0 - let lastChange = performance.now() - const tick = () => { - const rows = document.querySelectorAll('[class~="group/msg"]').length - if (rows > 0 && perf.firstRowAt == null) perf.firstRowAt = performance.now() - if (rows !== lastCount) { lastCount = rows; lastChange = performance.now(); perf.rowCount = rows } - if (rows > 0 && performance.now() - lastChange > 1500 && !perf.done) { - perf.stableAt = lastChange - perf.domNodes = document.getElementsByTagName('*').length - perf.heapMB = performance.memory ? Math.round(performance.memory.usedJSHeapSize / 1048576) : null - perf.done = true - return - } - setTimeout(tick, 50) - } - if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', tick) - else tick() -})()` - -/** Aggregates react-scan onRender callbacks into per-component totals on window.__reactScanAgg. */ -const REACT_SCAN_CONFIG = `(() => { - if (typeof window.reactScan !== 'function') return - window.__reactScanAgg = Object.create(null) - window.reactScan({ - showToolbar: false, - log: false, - showFPS: false, - showNotificationCount: false, - trackUnnecessaryRenders: true, - onRender: (fiber, renders) => { - const t = fiber && fiber.type - const name = (t && (t.displayName || t.name)) || (typeof t === 'string' ? t : 'Unknown') - const agg = window.__reactScanAgg[name] || (window.__reactScanAgg[name] = { renders: 0, time: 0, unnecessary: 0 }) - for (const r of renders) { - agg.renders++ - agg.time += r.time || 0 - if (r.unnecessary) agg.unnecessary++ - } - }, - }) -})()` - -async function snapshotReactScan(page, top = 12) { - return page.evaluate((topN) => { - const agg = window.__reactScanAgg || {} - const rows = Object.entries(agg) - .map(([name, a]) => ({ name, renders: a.renders, timeMs: Math.round(a.time * 10) / 10, unnecessary: a.unnecessary })) - .sort((a, b) => b.timeMs - a.timeMs) - .slice(0, topN) - window.__reactScanAgg = Object.create(null) - return rows - }, top) -} - -async function measureRun(context, url, { screenshotPath } = {}) { - const page = await context.newPage() - const consoleErrors = [] - page.on('console', (msg) => { if (msg.type() === 'error') consoleErrors.push(msg.text()) }) - - let apiMs = null - let apiBytes = null - page.on('response', async (res) => { - if (res.url().includes(`/api/mothership/chats/${CHAT_ID}`) && res.request().method() === 'GET') { - try { apiBytes = (await res.body()).length } catch {} - } - }) - page.on('requestfinished', (req) => { - if (req.url().includes(`/api/mothership/chats/${CHAT_ID}`) && req.method() === 'GET') { - apiMs = Math.round(req.timing().responseEnd) - } - }) - - await page.goto(url, { waitUntil: 'commit', timeout: 180_000 }) - await page.waitForFunction(() => window.__chatPerf?.done === true, null, { timeout: 90_000 }) - const m = await page.evaluate(() => window.__chatPerf) - - if (page.url().includes('/login')) throw new Error('Redirected to /login — session cookie rejected') - if (screenshotPath) await page.screenshot({ path: screenshotPath, fullPage: false }) - - const loadRenders = REACT_SCAN ? await snapshotReactScan(page) : null - - const textarea = page.locator('textarea').first() - let typing = null - try { - await textarea.click({ timeout: 5000 }) - await page.evaluate(() => { - const lat = [] - window.__keyLat = lat - document.addEventListener('keydown', () => { - const t = performance.now() - requestAnimationFrame(() => lat.push(performance.now() - t)) - }, { capture: true }) - }) - const text = 'why is the chat so slow when the transcript is long? '.repeat(5) - await page.keyboard.type(text, { delay: 10 }) - typing = await page.evaluate(() => { - const lat = (window.__keyLat || []).slice().sort((a, b) => a - b) - if (!lat.length) return null - return { - keys: lat.length, - avgKeyMs: Math.round(lat.reduce((s, d) => s + d, 0) / lat.length * 10) / 10, - p95KeyMs: Math.round(lat[Math.floor(lat.length * 0.95)] * 10) / 10, - maxKeyMs: Math.round(lat[lat.length - 1] * 10) / 10, - } - }) - } catch { /* typing probe is best-effort */ } - - const typingRenders = REACT_SCAN ? await snapshotReactScan(page) : null - - let streaming = null - let streamRenders = null - if (SEND) { - try { - await page.evaluate(() => { if (window.__keyLat) window.__keyLat.length = 0 }) - await page.keyboard.press('Enter') - await page.waitForTimeout(1500) - const probeUntil = Date.now() + 15_000 - while (Date.now() < probeUntil) { - await page.keyboard.type('still typing while the assistant streams ', { delay: 30 }) - } - streaming = await page.evaluate(() => { - const lat = (window.__keyLat || []).slice().sort((a, b) => a - b) - if (!lat.length) return null - return { - keys: lat.length, - avgKeyMs: Math.round(lat.reduce((s, d) => s + d, 0) / lat.length * 10) / 10, - p95KeyMs: Math.round(lat[Math.floor(lat.length * 0.95)] * 10) / 10, - maxKeyMs: Math.round(lat[lat.length - 1] * 10) / 10, - } - }) - streamRenders = REACT_SCAN ? await snapshotReactScan(page) : null - await page.screenshot({ path: `/tmp/chat-perf-send-${CHAT_ID.slice(0, 8)}.png` }) - } catch (e) { - console.error(` send probe failed: ${e.message.split('\n')[0]}`) - } - } - - await page.close() - return { - loadRenders, - typingRenders, - streaming, - streamRenders, - apiMs, - apiKB: apiBytes != null ? Math.round(apiBytes / 1024) : null, - firstRowMs: m.firstRowAt != null ? Math.round(m.firstRowAt) : null, - allRowsMs: m.stableAt != null ? Math.round(m.stableAt) : null, - rowCount: m.rowCount, - domNodes: m.domNodes, - heapMB: m.heapMB, - longTasks: m.longTasks, - longTaskMs: Math.round(m.longTaskMs), - maxLongTaskMs: Math.round(m.maxLongTaskMs), - typing, - consoleErrors: consoleErrors.slice(0, 5), - } -} - -const cookie = mintSessionCookie() -const browser = await chromium.launch({ headless: !HEADED }) -const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }) -await context.addCookies([cookie]) -if (REACT_SCAN) { - await context.addInitScript(readFileSync(REACT_SCAN_BUNDLE, 'utf8')) - await context.addInitScript(REACT_SCAN_CONFIG) -} -await context.addInitScript(INIT_SCRIPT) - -const url = `${BASE}/workspace/${WORKSPACE_ID}/chat/${CHAT_ID}` -console.log(`Measuring ${url} (${RUNS} runs + warmup)`) - -try { - await measureRun(context, url) // warmup: Next.js dev compiles the route on first hit -} catch (e) { - console.error(`Warmup failed: ${e.message}`) - await browser.close() - process.exit(1) -} - -const results = [] -for (let i = 0; i < RUNS; i++) { - const screenshotPath = i === 0 ? `/tmp/chat-perf-${CHAT_ID.slice(0, 8)}.png` : undefined - const r = await measureRun(context, url, { screenshotPath }) - results.push(r) - console.log(`run ${i + 1}: api=${r.apiMs}ms (${r.apiKB}KB) firstRow=${r.firstRowMs}ms allRows=${r.allRowsMs}ms rows=${r.rowCount} dom=${r.domNodes} heap=${r.heapMB}MB longTasks=${r.longTasks}/${r.longTaskMs}ms(max ${r.maxLongTaskMs}ms) keystroke avg=${r.typing?.avgKeyMs}ms p95=${r.typing?.p95KeyMs}ms max=${r.typing?.maxKeyMs}ms (${r.typing?.keys} keys)`) - if (r.consoleErrors.length) console.log(` console errors: ${r.consoleErrors.join(' | ')}`) - if (r.loadRenders) { - console.log(' react-scan LOAD phase (top by self time):') - for (const c of r.loadRenders) console.log(` ${c.name}: ${c.renders} renders, ${c.timeMs}ms, ${c.unnecessary} unnecessary`) - console.log(' react-scan TYPING phase (top by self time):') - for (const c of r.typingRenders ?? []) console.log(` ${c.name}: ${c.renders} renders, ${c.timeMs}ms, ${c.unnecessary} unnecessary`) - } - if (r.streaming) { - console.log(` STREAMING keystroke: avg=${r.streaming.avgKeyMs}ms p95=${r.streaming.p95KeyMs}ms max=${r.streaming.maxKeyMs}ms (${r.streaming.keys} keys)`) - if (r.streamRenders) { - console.log(' react-scan STREAM phase (top by self time):') - for (const c of r.streamRenders) console.log(` ${c.name}: ${c.renders} renders, ${c.timeMs}ms, ${c.unnecessary} unnecessary`) - } - } -} - -const median = (key, sub) => { - const vals = results.map((r) => (sub ? r[key]?.[sub] : r[key])).filter((v) => v != null).sort((a, b) => a - b) - return vals.length ? vals[Math.floor(vals.length / 2)] : null -} - -console.log('\nmedian:', JSON.stringify({ - apiMs: median('apiMs'), - apiKB: median('apiKB'), - firstRowMs: median('firstRowMs'), - allRowsMs: median('allRowsMs'), - rowCount: median('rowCount'), - domNodes: median('domNodes'), - heapMB: median('heapMB'), - longTasks: median('longTasks'), - longTaskMs: median('longTaskMs'), - maxLongTaskMs: median('maxLongTaskMs'), - keystrokeAvgMs: median('typing', 'avgKeyMs'), - keystrokeP95Ms: median('typing', 'p95KeyMs'), - keystrokeMaxMs: median('typing', 'maxKeyMs'), -})) - -await browser.close() diff --git a/scripts/perf/seed-chat-scale.mjs b/scripts/perf/seed-chat-scale.mjs deleted file mode 100644 index a621ca2e883..00000000000 --- a/scripts/perf/seed-chat-scale.mjs +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Seeds synthetic copilot chats at arbitrary message counts by cycling the - * messages of an existing source chat. Each copy gets a fresh message_id - * (mirrored into content.id, which the client uses as the React key) and a - * monotonically increasing seq/created_at so ordering is preserved. - * - * Usage: - * node scripts/perf/seed-chat-scale.mjs --source [--sizes 32,258,516,1032] - * - * Prints one line per created chat: - * Cleanup: delete from copilot_chats where title like 'PERF n=%'; - */ -import { execFileSync } from 'node:child_process' -import { readFileSync } from 'node:fs' -import { dirname, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' - -const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..') - -function arg(name, fallback) { - const i = process.argv.indexOf(`--${name}`) - return i >= 0 ? process.argv[i + 1] : fallback -} - -const SOURCE = arg('source') -const SIZES = arg('sizes', '32,258,516,1032').split(',').map(Number) -if (!SOURCE || SIZES.some(Number.isNaN)) { - console.error('Usage: node scripts/perf/seed-chat-scale.mjs --source [--sizes 32,258,516]') - process.exit(1) -} - -const env = readFileSync(resolve(ROOT, 'apps/sim/.env'), 'utf8') -const DB_URL = env.match(/^DATABASE_URL="?([^"\n]+)"?$/m)[1] - -const psql = (sql) => execFileSync('psql', [DB_URL, '-At', '-c', sql], { encoding: 'utf8' }).trim() - -for (const size of SIZES) { - const sql = ` - with new_chat as ( - insert into copilot_chats (id, user_id, workflow_id, title, model, workspace_id, type, resources, created_at, updated_at) - select gen_random_uuid(), user_id, null, 'PERF n=${size}', model, workspace_id, type, resources, now(), now() - from copilot_chats where id = '${SOURCE}' - returning id - ), - src as ( - select role, content, model, tokens_in, tokens_out, - row_number() over (order by seq asc nulls last, created_at asc, id asc) as rn, - count(*) over () as total - from copilot_messages - where chat_id = '${SOURCE}' and deleted_at is null - ), - expanded as ( - select s.*, g.copy_idx, (g.copy_idx * s.total + s.rn) as new_seq - from src s - cross join generate_series(0, ${size} / 1 / (select min(total) from src) + 1) as g(copy_idx) - ), - prepared as ( - select role, content, model, tokens_in, tokens_out, new_seq, - gen_random_uuid()::text as new_mid - from expanded - where new_seq <= ${size} - ) - insert into copilot_messages (id, chat_id, message_id, role, content, model, tokens_in, tokens_out, seq, created_at, updated_at) - select gen_random_uuid(), (select id from new_chat), new_mid, role, - jsonb_set(content, '{id}', to_jsonb(new_mid)), - model, tokens_in, tokens_out, new_seq, - now() - interval '1 day' + (new_seq * interval '1 second'), - now() - interval '1 day' + (new_seq * interval '1 second') - from prepared - returning chat_id` - const out = psql(sql).split('\n').filter(Boolean) - const chatId = out[0] - const count = psql(`select count(*) from copilot_messages where chat_id = '${chatId}'`) - console.log(`${size} ${chatId} (inserted ${count} messages)`) -} diff --git a/scripts/perf/stream-validate.mjs b/scripts/perf/stream-validate.mjs deleted file mode 100644 index 1b287beb3e8..00000000000 --- a/scripts/perf/stream-validate.mjs +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Streaming correctness probe for the virtualized transcript. - * - * Opens a fresh chat, sends a prompt, and samples the scroll container while the - * assistant streams. Asserts the streaming reply grows monotonically AND the - * container stays pinned to the bottom (auto-scroll follows the growing tail) — - * the two behaviors most at risk from virtualizing the message list. - * - * Usage: - * node scripts/perf/stream-validate.mjs --workspace [--base http://localhost:3000] [--prompt "..."] - */ -import { execFileSync } from 'node:child_process' -import { createHmac } from 'node:crypto' -import { readFileSync } from 'node:fs' -import { dirname, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { chromium } from 'playwright' - -const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..') - -function arg(name, fallback) { - const i = process.argv.indexOf(`--${name}`) - return i >= 0 ? process.argv[i + 1] : fallback -} - -const WORKSPACE_ID = arg('workspace') -const BASE = arg('base', 'http://localhost:3000') -const EMAIL = arg('email', 'waleed@sim.ai') -const PROMPT = arg( - 'prompt', - 'Write about 400 words explaining how database indexes work, with a few short bullet lists.' -) - -if (!WORKSPACE_ID) { - console.error('Usage: node scripts/perf/stream-validate.mjs --workspace ') - process.exit(1) -} - -function readEnv(key) { - const env = readFileSync(resolve(ROOT, 'apps/sim/.env'), 'utf8') - const m = env.match(new RegExp(`^${key}="?([^"\n]+)"?$`, 'm')) - if (!m) throw new Error(`${key} not found in apps/sim/.env`) - return m[1] -} - -function mintSessionCookie() { - const dbUrl = readEnv('DATABASE_URL') - const secret = readEnv('BETTER_AUTH_SECRET') - const token = execFileSync( - 'psql', - [dbUrl, '-At', '-c', - `select token from session where user_id = (select id from "user" where email = '${EMAIL}') and expires_at > now() order by expires_at desc limit 1`], - { encoding: 'utf8' } - ).trim() - if (!token) throw new Error(`No live session for ${EMAIL}`) - const signature = createHmac('sha256', secret).update(token).digest('base64') - return { - name: 'better-auth.session_token', - value: encodeURIComponent(`${token}.${signature}`), - domain: 'localhost', - path: '/', - httpOnly: true, - sameSite: 'Lax', - } -} - -const browser = await chromium.launch({ headless: true }) -const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }) -await context.addCookies([mintSessionCookie()]) -const page = await context.newPage() - -await page.goto(`${BASE}/workspace/${WORKSPACE_ID}/home`, { waitUntil: 'commit', timeout: 180_000 }) -const textarea = page.locator('textarea').first() -await textarea.waitFor({ state: 'visible', timeout: 60_000 }) -await textarea.click() -await page.keyboard.type(PROMPT, { delay: 5 }) -await page.keyboard.press('Enter') - -/** Sample the scroll container + streaming reply length every 250ms for ~20s. */ -const samples = await page.evaluate(async () => { - const out = [] - const scroller = () => - document.querySelector('[class*="overflow-y-auto"]') - const lastAssistantLen = () => { - const rows = document.querySelectorAll('[class~="group/msg"]') - const last = rows[rows.length - 1] - return last ? (last.textContent || '').length : 0 - } - for (let i = 0; i < 80; i++) { - const el = scroller() - if (el) { - out.push({ - t: i * 250, - len: lastAssistantLen(), - distanceFromBottom: Math.round(el.scrollHeight - el.scrollTop - el.clientHeight), - rows: document.querySelectorAll('[class~="group/msg"]').length, - }) - } - await new Promise((r) => setTimeout(r, 250)) - } - return out -}) - -await page.screenshot({ path: '/tmp/stream-validate.png' }) -await browser.close() - -const grew = samples.filter((s) => s.len > 0) -const maxLen = Math.max(0, ...samples.map((s) => s.len)) -const firstGrowth = grew.find((s) => s.len > 20) -const peakIdx = samples.findIndex((s) => s.len === maxLen) -// During active growth, the container should track the bottom (small distance). -const growthWindow = samples.filter((s, i) => i <= peakIdx && s.len > 20) -const pinnedDuringGrowth = growthWindow.filter((s) => s.distanceFromBottom <= 80).length -const pinnedRatio = growthWindow.length ? pinnedDuringGrowth / growthWindow.length : 0 -const monotonic = grew.every((s, i, a) => i === 0 || s.len >= a[i - 1].len - 5) - -console.log(JSON.stringify({ - streamed: maxLen > 40, - maxReplyChars: maxLen, - firstGrowthAtMs: firstGrowth?.t ?? null, - monotonicGrowth: monotonic, - pinnedDuringGrowthRatio: Math.round(pinnedRatio * 100) / 100, - maxDistanceDuringGrowth: growthWindow.length ? Math.max(...growthWindow.map((s) => s.distanceFromBottom)) : null, - finalRows: samples.at(-1)?.rows ?? 0, -}, null, 2)) - -if (maxLen <= 40) { - console.error('\n⚠️ No assistant stream detected — cannot validate streaming follow.') - process.exit(2) -} From 4fc3ad73c7f57b690c91b51e4a150683e1bba096 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 12 Jun 2026 18:46:50 -0700 Subject: [PATCH 3/4] fix(mothership): preserve user-row top spacing in virtualized layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restoring pt-3/pt-2 on the user row keeps the exact inter-row rhythm from the old space-y-6 layout: assistant→user gaps stay 24+12px and user→assistant stay 24px, instead of becoming uniform when the gap moved to per-row pb. --- .../home/components/mothership-chat/mothership-chat.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 a81795c9cfb..afcdd9fa430 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 @@ -93,7 +93,7 @@ const LAYOUT_STYLES = { 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable_both-edges]', sizer: 'relative mx-auto w-full max-w-[48rem]', rowGap: 'pb-6', - userRow: 'flex flex-col items-end gap-[6px]', + userRow: 'flex flex-col items-end gap-[6px] pt-3', attachmentWidth: 'max-w-[70%]', userBubble: 'max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2', assistantRow: 'group/msg', @@ -104,7 +104,7 @@ const LAYOUT_STYLES = { scrollContainer: 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4', sizer: 'relative w-full', rowGap: 'pb-4', - userRow: 'flex flex-col items-end gap-[6px]', + userRow: 'flex flex-col items-end gap-[6px] pt-2', attachmentWidth: 'max-w-[85%]', userBubble: 'max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2', assistantRow: 'group/msg', From 73bea3039027fe4289330f1a851573abe53f9c99 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 12 Jun 2026 19:06:10 -0700 Subject: [PATCH 4/4] fix(mothership): don't re-scroll when a pending chat persists its id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-chat initial-scroll guard treated undefined→persisted-id as a chat switch and scrolled to the bottom again, yanking the viewport down if the user had scrolled up mid-stream. Treat that transition as the same conversation: adopt the id without re-scrolling. Genuine chat switches (id→different id) still re-scroll. --- .../components/mothership-chat/mothership-chat.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 afcdd9fa430..e13bab2f1a8 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 @@ -344,14 +344,20 @@ export function MothershipChat({ * Land at the most recent message once per chat — on open and when switching * chats. The ref tracks which `chatId` we last scrolled for (seeded with * {@link UNSCROLLED} so a pending, id-less chat still scrolls on first mount), - * so it re-fires on every chat change, including between chats of equal length. - * Runs before paint so a long transcript never flashes at the top. Subsequent + * so it re-fires on a genuine chat switch, including between chats of equal + * length. A pending chat persisting its id (`undefined` → string) is the SAME + * conversation, so adopt the id without re-scrolling — otherwise the viewport + * would snap back to the bottom after the user scrolled up mid-stream. Runs + * before paint so a long transcript never flashes at the top. Subsequent * growth within the same chat is handled by {@link useAutoScroll}'s streaming * sticky-scroll, not here. */ useLayoutEffect(() => { - if (!hasMessages || initialScrollBlocked || scrolledChatRef.current === chatId) return + const scrolledFor = scrolledChatRef.current + if (!hasMessages || initialScrollBlocked || scrolledFor === chatId) return + const isPendingPersist = scrolledFor === undefined && chatId !== undefined scrolledChatRef.current = chatId + if (isPendingPersist) return virtualizer.scrollToIndex(lastIndex, { align: 'end' }) }, [chatId, hasMessages, initialScrollBlocked, lastIndex, virtualizer])