diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx
index 1160a6e6c9..79e3d56d00 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx
@@ -34,7 +34,7 @@ import 'prismjs/components/prism-python'
import { cn } from '@/lib/core/utils/cn'
import { extractTextContent } from '@/lib/core/utils/react-node-text'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
-import { useAutoScroll } from '@/hooks/use-auto-scroll'
+import { useScrollAnchor } from '@/hooks/use-scroll-anchor'
import { DataTable } from './data-table'
import { ZoomablePreview } from './zoomable-preview'
@@ -866,7 +866,10 @@ const MarkdownPreview = memo(function MarkdownPreview({
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
}) {
const { push: navigate } = useRouter()
- const { ref: autoScrollRef } = useAutoScroll(isStreaming && !disableAutoScroll)
+ const { ref: autoScrollRef, spacerRef } = useScrollAnchor(
+ isStreaming && !disableAutoScroll,
+ content
+ )
const contentRef = useRef(content)
contentRef.current = content
@@ -921,16 +924,13 @@ const MarkdownPreview = memo(function MarkdownPreview({
>
{markdownContent}
+
)
return (
- {onCheckboxToggle ? (
- {body}
- ) : (
- body
- )}
+ {body}
)
})
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts
index 70e14ca0ea..88982dac9b 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts
@@ -337,3 +337,99 @@ describe('syncTextEditorContentState — streaming finalize shortcuts', () => {
expect(next.content).toBe('v2')
})
})
+
+describe('syncTextEditorContentState — inter-session content shrink (replace mode)', () => {
+ it('replaces long linger content with a short first chunk from a new session', () => {
+ const lingerState = streaming(
+ 'a very long document with many paragraphs',
+ 'a very long document with many paragraphs',
+ ''
+ )
+ const next = syncTextEditorContentState(lingerState, {
+ canReconcileToFetchedContent: false,
+ fetchedContent: undefined,
+ streamingContent: 'short',
+ streamingMode: 'replace',
+ })
+ expect(next.phase).toBe('streaming')
+ expect(next.content).toBe('short')
+ expect(next.lastStreamedContent).toBe('short')
+ })
+
+ it('correctly transitions to the new chunk even when it is a single character', () => {
+ const lingerState = streaming(
+ 'full document\nmany lines\nof content',
+ 'full document\nmany lines\nof content',
+ ''
+ )
+ const next = syncTextEditorContentState(lingerState, {
+ canReconcileToFetchedContent: false,
+ fetchedContent: undefined,
+ streamingContent: '#',
+ streamingMode: 'replace',
+ })
+ expect(next.phase).toBe('streaming')
+ expect(next.content).toBe('#')
+ })
+
+ it('does not finalize early when the new short chunk happens to equal savedContent', () => {
+ const lingerState = streaming('long content', 'long content', 'old saved')
+ const next = syncTextEditorContentState(lingerState, {
+ canReconcileToFetchedContent: false,
+ fetchedContent: undefined,
+ streamingContent: '',
+ streamingMode: 'replace',
+ })
+ expect(next.phase).toBe('streaming')
+ expect(next.content).toBe('')
+ })
+
+ it('stays streaming across multiple growing chunks after the shrink', () => {
+ const lingerState = streaming('final long document', 'final long document', '')
+
+ const chunk1 = syncTextEditorContentState(lingerState, {
+ canReconcileToFetchedContent: false,
+ fetchedContent: undefined,
+ streamingContent: '# New',
+ streamingMode: 'replace',
+ })
+ expect(chunk1.phase).toBe('streaming')
+ expect(chunk1.content).toBe('# New')
+
+ const chunk2 = syncTextEditorContentState(chunk1, {
+ canReconcileToFetchedContent: false,
+ fetchedContent: undefined,
+ streamingContent: '# New Section\n\nSome text',
+ streamingMode: 'replace',
+ })
+ expect(chunk2.phase).toBe('streaming')
+ expect(chunk2.content).toBe('# New Section\n\nSome text')
+
+ const chunk3 = syncTextEditorContentState(chunk2, {
+ canReconcileToFetchedContent: false,
+ fetchedContent: undefined,
+ streamingContent: '# New Section\n\nSome text that is now longer than the original',
+ streamingMode: 'replace',
+ })
+ expect(chunk3.phase).toBe('streaming')
+ expect(chunk3.content).toBe('# New Section\n\nSome text that is now longer than the original')
+ })
+
+ it('synthetic file (canReconcile=false) finalizes with current content when streaming ends', () => {
+ const finalChunk = streaming(
+ '# Complete Document\n\nAll done.',
+ '# Complete Document\n\nAll done.',
+ ''
+ )
+ const next = syncTextEditorContentState(finalChunk, {
+ canReconcileToFetchedContent: false,
+ fetchedContent: undefined,
+ streamingContent: undefined,
+ streamingMode: 'replace',
+ })
+ expect(next.phase).toBe('ready')
+ expect(next.content).toBe('# Complete Document\n\nAll done.')
+ expect(next.savedContent).toBe('# Complete Document\n\nAll done.')
+ expect(next.lastStreamedContent).toBeNull()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx
index be06ee8481..48953eb1e9 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx
@@ -30,11 +30,11 @@ function shouldShowStreamingFilePanel(
previewSession: FilePreviewSession | null | undefined,
active: MothershipResource | null
): boolean {
- if (!previewSession || previewSession.status === 'complete' || !active) return false
+ if (!previewSession || !hasRenderableFilePreviewContent(previewSession) || !active) return false
if (active.id === 'streaming-file') return true
if (active.type !== 'file') return false
if (active.id && previewSession.fileId === active.id) {
- return hasRenderableFilePreviewContent(previewSession)
+ return true
}
return false
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
index 4e7d5138e6..d4a5e9628f 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
@@ -3402,7 +3402,11 @@ export function useChat(
if (parentToolCallId) {
subagentByParentToolCallId.delete(parentToolCallId)
}
- if (previewSessionRef.current && !activePreviewSessionIdRef.current) {
+ if (
+ previewSessionRef.current &&
+ (!activePreviewSessionIdRef.current ||
+ previewSessionRef.current.status === 'complete')
+ ) {
const lastFileResource = resourcesRef.current.find(
(r) => r.type === 'file' && r.id !== 'streaming-file'
)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions.test.tsx
index 8a4887b846..d5b6508643 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions.test.tsx
@@ -175,7 +175,7 @@ describe('reduceFilePreviewSessions', () => {
expect(completedState.sessions['preview-1']?.id).toBe('preview-1')
})
- it('clears active session when the only session completes', () => {
+ it('lingers on the completed session when it is the only one (no successor)', () => {
const onlyStreaming = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
type: 'upsert',
session: createSession({
@@ -200,10 +200,96 @@ describe('reduceFilePreviewSessions', () => {
}),
})
- expect(completed.activeSessionId).toBeNull()
+ expect(completed.activeSessionId).toBe('preview-1')
expect(completed.sessions['preview-1']?.status).toBe('complete')
})
+ it('releases the linger when a new non-complete session upserts', () => {
+ const lingered = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
+ type: 'upsert',
+ session: createSession({
+ id: 'preview-1',
+ toolCallId: 'preview-1',
+ previewVersion: 2,
+ previewText: 'section one',
+ }),
+ })
+ const afterComplete = reduceFilePreviewSessions(lingered, {
+ type: 'complete',
+ session: createSession({
+ id: 'preview-1',
+ toolCallId: 'preview-1',
+ status: 'complete',
+ previewVersion: 3,
+ completedAt: '2026-04-10T00:00:02.000Z',
+ previewText: 'section one',
+ }),
+ })
+
+ // New tool call arrives with content — should switch active to the new session.
+ const afterNew = reduceFilePreviewSessions(afterComplete, {
+ type: 'upsert',
+ session: createSession({
+ id: 'preview-2',
+ toolCallId: 'preview-2',
+ status: 'streaming',
+ previewVersion: 1,
+ previewText: 'section two',
+ }),
+ })
+
+ expect(afterNew.activeSessionId).toBe('preview-2')
+ })
+
+ it('holds the linger when an empty pending session arrives (no content yet)', () => {
+ const lingered = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
+ type: 'upsert',
+ session: createSession({
+ id: 'preview-1',
+ toolCallId: 'preview-1',
+ previewVersion: 2,
+ previewText: 'existing content',
+ }),
+ })
+ const afterComplete = reduceFilePreviewSessions(lingered, {
+ type: 'complete',
+ session: createSession({
+ id: 'preview-1',
+ toolCallId: 'preview-1',
+ status: 'complete',
+ previewVersion: 3,
+ completedAt: '2026-04-10T00:00:02.000Z',
+ previewText: 'existing content',
+ }),
+ })
+
+ const afterEmptyUpsert = reduceFilePreviewSessions(afterComplete, {
+ type: 'upsert',
+ session: createSession({
+ id: 'preview-2',
+ toolCallId: 'preview-2',
+ status: 'pending',
+ previewVersion: 0,
+ previewText: '',
+ }),
+ })
+
+ expect(afterEmptyUpsert.activeSessionId).toBe('preview-1')
+
+ const afterContent = reduceFilePreviewSessions(afterEmptyUpsert, {
+ type: 'upsert',
+ session: createSession({
+ id: 'preview-2',
+ toolCallId: 'preview-2',
+ status: 'streaming',
+ previewVersion: 1,
+ previewText: 'new content',
+ }),
+ })
+
+ expect(afterContent.activeSessionId).toBe('preview-2')
+ })
+
it('ignores stale complete events for a newer active session', () => {
const activeState = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
type: 'upsert',
@@ -231,4 +317,249 @@ describe('reduceFilePreviewSessions', () => {
expect(staleCompleteState.sessions['preview-1']?.status).toBe('streaming')
expect(staleCompleteState.sessions['preview-1']?.previewVersion).toBe(3)
})
+
+ it('removes a session and clears activeSessionId when the active session is removed', () => {
+ const withSession = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
+ type: 'upsert',
+ session: createSession({
+ id: 'preview-1',
+ toolCallId: 'preview-1',
+ previewVersion: 1,
+ previewText: 'content',
+ }),
+ })
+
+ const removed = reduceFilePreviewSessions(withSession, {
+ type: 'remove',
+ sessionId: 'preview-1',
+ })
+
+ expect(removed.activeSessionId).toBeNull()
+ expect(removed.sessions['preview-1']).toBeUndefined()
+ })
+
+ it('removes a non-active session without changing activeSessionId', () => {
+ let state = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
+ type: 'upsert',
+ session: createSession({
+ id: 'preview-1',
+ toolCallId: 'preview-1',
+ previewVersion: 1,
+ previewText: 'active',
+ }),
+ })
+ state = reduceFilePreviewSessions(state, {
+ type: 'upsert',
+ session: createSession({
+ id: 'preview-2',
+ toolCallId: 'preview-2',
+ previewVersion: 1,
+ previewText: 'inactive',
+ status: 'complete',
+ completedAt: '2026-04-10T00:00:01.000Z',
+ }),
+ })
+
+ const removed = reduceFilePreviewSessions(state, {
+ type: 'remove',
+ sessionId: 'preview-2',
+ })
+
+ expect(removed.activeSessionId).toBe('preview-1')
+ expect(removed.sessions['preview-2']).toBeUndefined()
+ expect(removed.sessions['preview-1']).toBeDefined()
+ })
+
+ it('removing a non-existent session is a no-op', () => {
+ const state = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
+ type: 'upsert',
+ session: createSession({ id: 'preview-1', toolCallId: 'preview-1', previewVersion: 1 }),
+ })
+
+ const next = reduceFilePreviewSessions(state, { type: 'remove', sessionId: 'does-not-exist' })
+
+ expect(next).toBe(state)
+ })
+
+ it('reset clears all sessions and activeSessionId', () => {
+ let state = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
+ type: 'upsert',
+ session: createSession({ id: 'preview-1', toolCallId: 'preview-1', previewVersion: 1 }),
+ })
+ state = reduceFilePreviewSessions(state, {
+ type: 'complete',
+ session: createSession({
+ id: 'preview-1',
+ toolCallId: 'preview-1',
+ status: 'complete',
+ previewVersion: 2,
+ completedAt: '2026-04-10T00:00:02.000Z',
+ previewText: 'final',
+ }),
+ })
+
+ const reset = reduceFilePreviewSessions(state, { type: 'reset' })
+
+ expect(reset.activeSessionId).toBeNull()
+ expect(Object.keys(reset.sessions)).toHaveLength(0)
+ })
+
+ it('hydrate with an empty sessions array is a no-op', () => {
+ const state = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
+ type: 'upsert',
+ session: createSession({ id: 'preview-1', toolCallId: 'preview-1', previewVersion: 1 }),
+ })
+
+ const next = reduceFilePreviewSessions(state, { type: 'hydrate', sessions: [] })
+
+ expect(next).toBe(state)
+ })
+
+ it('hydrate merges incoming sessions into existing state without replacing non-stale sessions', () => {
+ const existing = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
+ type: 'upsert',
+ session: createSession({
+ id: 'preview-1',
+ toolCallId: 'preview-1',
+ previewVersion: 3,
+ updatedAt: '2026-04-10T00:00:03.000Z',
+ previewText: 'current',
+ }),
+ })
+
+ const hydrated = reduceFilePreviewSessions(existing, {
+ type: 'hydrate',
+ sessions: [
+ createSession({
+ id: 'preview-1',
+ toolCallId: 'preview-1',
+ previewVersion: 2,
+ updatedAt: '2026-04-10T00:00:02.000Z',
+ previewText: 'stale',
+ }),
+ createSession({
+ id: 'preview-2',
+ toolCallId: 'preview-2',
+ previewVersion: 1,
+ updatedAt: '2026-04-10T00:00:04.000Z',
+ previewText: 'new',
+ }),
+ ],
+ })
+
+ expect(hydrated.sessions['preview-1']?.previewVersion).toBe(3)
+ expect(hydrated.sessions['preview-1']?.previewText).toBe('current')
+ expect(hydrated.sessions['preview-2']?.previewText).toBe('new')
+ })
+
+ it('hydrate preserves linger when no non-complete session exists in incoming batch', () => {
+ const lingered = reduceFilePreviewSessions(
+ reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
+ type: 'upsert',
+ session: createSession({
+ id: 'preview-1',
+ toolCallId: 'preview-1',
+ previewVersion: 2,
+ previewText: 'final',
+ }),
+ }),
+ {
+ type: 'complete',
+ session: createSession({
+ id: 'preview-1',
+ toolCallId: 'preview-1',
+ status: 'complete',
+ previewVersion: 3,
+ completedAt: '2026-04-10T00:00:02.000Z',
+ previewText: 'final',
+ }),
+ }
+ )
+
+ // Hydrate with the same completed session — no non-complete successor.
+ const afterHydrate = reduceFilePreviewSessions(lingered, {
+ type: 'hydrate',
+ sessions: [
+ createSession({
+ id: 'preview-1',
+ toolCallId: 'preview-1',
+ status: 'complete',
+ previewVersion: 3,
+ previewText: 'final',
+ completedAt: '2026-04-10T00:00:02.000Z',
+ }),
+ ],
+ })
+
+ expect(afterHydrate.activeSessionId).toBe('preview-1')
+ })
+
+ it('hydrate releases linger when a non-complete session is present in the incoming batch', () => {
+ const lingered = reduceFilePreviewSessions(
+ reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
+ type: 'upsert',
+ session: createSession({
+ id: 'preview-1',
+ toolCallId: 'preview-1',
+ previewVersion: 2,
+ previewText: 'final',
+ }),
+ }),
+ {
+ type: 'complete',
+ session: createSession({
+ id: 'preview-1',
+ toolCallId: 'preview-1',
+ status: 'complete',
+ previewVersion: 3,
+ completedAt: '2026-04-10T00:00:02.000Z',
+ previewText: 'final',
+ }),
+ }
+ )
+
+ const afterHydrate = reduceFilePreviewSessions(lingered, {
+ type: 'hydrate',
+ sessions: [
+ createSession({
+ id: 'preview-2',
+ toolCallId: 'preview-2',
+ status: 'streaming',
+ previewVersion: 1,
+ previewText: 'new content',
+ }),
+ ],
+ })
+
+ expect(afterHydrate.activeSessionId).toBe('preview-2')
+ })
+
+ it('complete for a non-active session updates the session but keeps activeSessionId', () => {
+ let state = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
+ type: 'upsert',
+ session: createSession({ id: 'preview-1', toolCallId: 'preview-1', previewVersion: 1 }),
+ })
+ state = reduceFilePreviewSessions(state, {
+ type: 'upsert',
+ session: createSession({
+ id: 'preview-2',
+ toolCallId: 'preview-2',
+ previewVersion: 1,
+ previewText: 'background',
+ }),
+ })
+ const completed = reduceFilePreviewSessions(state, {
+ type: 'complete',
+ session: createSession({
+ id: 'preview-1',
+ toolCallId: 'preview-1',
+ status: 'complete',
+ previewVersion: 2,
+ completedAt: '2026-04-10T00:00:02.000Z',
+ }),
+ })
+
+ expect(completed.activeSessionId).toBe('preview-2')
+ expect(completed.sessions['preview-1']?.status).toBe('complete')
+ })
})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions.ts
index 28ca3ad441..b9a650e4ac 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions.ts
@@ -90,9 +90,10 @@ export function reduceFilePreviewSessions(
}
}
+ const successor = pickActiveSessionId(nextSessions, state.activeSessionId)
return {
sessions: nextSessions,
- activeSessionId: pickActiveSessionId(nextSessions, state.activeSessionId),
+ activeSessionId: successor ?? state.activeSessionId,
}
}
@@ -106,15 +107,22 @@ export function reduceFilePreviewSessions(
[action.session.id]: action.session,
}
- return {
- sessions: nextSessions,
- activeSessionId:
- action.activate === false
- ? pickActiveSessionId(nextSessions, state.activeSessionId)
- : action.session.status === 'complete'
- ? pickActiveSessionId(nextSessions, state.activeSessionId)
- : action.session.id,
+ let nextActiveSessionId: string | null
+ if (action.activate === false || action.session.status === 'complete') {
+ const successor = pickActiveSessionId(nextSessions, state.activeSessionId)
+ nextActiveSessionId = successor ?? state.activeSessionId
+ } else {
+ // Don't switch to a new session until it has renderable content — keeps the viewer mounted.
+ const currentActive = state.activeSessionId ? nextSessions[state.activeSessionId] : null
+ const currentHasContent = currentActive
+ ? hasRenderableFilePreviewContent(currentActive)
+ : false
+ const incomingHasContent = hasRenderableFilePreviewContent(action.session)
+ nextActiveSessionId =
+ currentHasContent && !incomingHasContent ? state.activeSessionId : action.session.id
}
+
+ return { sessions: nextSessions, activeSessionId: nextActiveSessionId }
}
case 'complete': {
@@ -127,12 +135,16 @@ export function reduceFilePreviewSessions(
[action.session.id]: action.session,
}
+ if (state.activeSessionId !== action.session.id) {
+ return { sessions: nextSessions, activeSessionId: state.activeSessionId }
+ }
+
+ const successor = pickActiveSessionId(nextSessions, null)
return {
sessions: nextSessions,
- activeSessionId:
- state.activeSessionId === action.session.id
- ? pickActiveSessionId(nextSessions, null)
- : state.activeSessionId,
+ // Linger on this session until a successor upserts. Without it, streamingContent
+ // becomes undefined between tool calls, collapsing the viewer and clipping scrollTop.
+ activeSessionId: successor ?? action.session.id,
}
}
diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts
index c70ad84341..552f2bf62c 100644
--- a/apps/sim/hooks/use-auto-scroll.ts
+++ b/apps/sim/hooks/use-auto-scroll.ts
@@ -49,11 +49,14 @@ export function useAutoScroll(
const el = containerRef.current
if (!el) return
- stickyRef.current = true
- userDetachedRef.current = false
+ // Don't jump if the user scrolled up — keep their position.
+ const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
+ const isNearBottom = distanceFromBottom <= STICK_THRESHOLD
+ stickyRef.current = isNearBottom
+ userDetachedRef.current = !isNearBottom
prevScrollTopRef.current = el.scrollTop
prevScrollHeightRef.current = el.scrollHeight
- scrollToBottom()
+ if (isNearBottom) scrollToBottom()
const detach = () => {
stickyRef.current = false
diff --git a/apps/sim/hooks/use-scroll-anchor.test.ts b/apps/sim/hooks/use-scroll-anchor.test.ts
new file mode 100644
index 0000000000..2a4737081b
--- /dev/null
+++ b/apps/sim/hooks/use-scroll-anchor.test.ts
@@ -0,0 +1,133 @@
+/**
+ * @vitest-environment node
+ *
+ * Tests for the pure functions extracted from `useScrollAnchor`:
+ * `computeSpacerShortage` and `shouldReengage`. The hook's DOM-interaction
+ * behaviour (event listeners, MutationObserver, and the forced-reflow /
+ * scroll-event race condition fix) requires a real browser layout engine
+ * and is covered by manual QA.
+ */
+import { describe, expect, it } from 'vitest'
+import { computeSpacerShortage, shouldReengage } from '@/hooks/use-scroll-anchor'
+
+describe('computeSpacerShortage', () => {
+ it('returns 0 when content is exactly tall enough', () => {
+ // user at 500, viewport 600 → needs 1100; content is exactly 1100
+ expect(computeSpacerShortage(500, 600, 1100, 0)).toBe(0)
+ })
+
+ it('returns 0 when content is taller than needed', () => {
+ // user at 500, viewport 600 → needs 1100; content is 2000
+ expect(computeSpacerShortage(500, 600, 2000, 0)).toBe(0)
+ })
+
+ it('returns 0 when user is at the very top (targetScrollTop = 0) and content fills viewport', () => {
+ // no scroll required; all content visible
+ expect(computeSpacerShortage(0, 600, 600, 0)).toBe(0)
+ })
+
+ it('returns 0 when user is at the very top and content exceeds viewport', () => {
+ expect(computeSpacerShortage(0, 600, 1000, 0)).toBe(0)
+ })
+
+ it('returns positive shortage when content shrank to almost nothing', () => {
+ // user at 500, viewport 600 → needs 1100; content shrank to 100
+ expect(computeSpacerShortage(500, 600, 100, 0)).toBe(1000)
+ })
+
+ it('returns positive shortage when content is shorter than viewport', () => {
+ // user at 200, viewport 600 → needs 800; content is 300
+ expect(computeSpacerShortage(200, 600, 300, 0)).toBe(500)
+ })
+
+ it('returns the exact gap when content is one pixel short', () => {
+ expect(computeSpacerShortage(500, 600, 1099, 0)).toBe(1)
+ })
+
+ it('subtracts existing spacer height before recomputing shortage', () => {
+ // spacer was 900 from last update; content grew to 1000 natural height
+ // scrollHeight = 1000 + 900 = 1900; needed = 500 + 600 = 1100
+ // naturalScrollHeight = 1900 - 900 = 1000; shortage = 1100 - 1000 = 100
+ expect(computeSpacerShortage(500, 600, 1900, 900)).toBe(100)
+ })
+
+ it('returns 0 and spacer should be cleared when content has grown past needed', () => {
+ // spacer was 500; content has now grown enough that no spacer is needed
+ // naturalScrollHeight = 2000 - 500 = 1500 > 1100 needed
+ expect(computeSpacerShortage(500, 600, 2000, 500)).toBe(0)
+ })
+
+ it('returns required spacer height even when scroll height already equals needed', () => {
+ // spacer was 900; natural content is 200; scrollHeight = 1100; needed = 1100
+ // naturalScrollHeight = 1100 - 900 = 200; shortage = 1100 - 200 = 900
+ // The function returns the new target minHeight (900), not the change delta (0)
+ expect(computeSpacerShortage(500, 600, 1100, 900)).toBe(900)
+ })
+
+ it('correctly recomputes when spacer is larger than needed', () => {
+ // spacer was over-inflated at 1200; content grew to 800 naturally
+ // scrollHeight = 2000; naturalScrollHeight = 2000 - 1200 = 800; needed = 1100
+ // shortage = 1100 - 800 = 300 (spacer should shrink from 1200 to 300)
+ expect(computeSpacerShortage(500, 600, 2000, 1200)).toBe(300)
+ })
+
+ it('handles large scroll positions correctly', () => {
+ // user at 5000, viewport 600 → needs 5600; content shrank to 200
+ expect(computeSpacerShortage(5000, 600, 200, 0)).toBe(5400)
+ })
+
+ it('handles user scrolled to the absolute bottom', () => {
+ // user at bottom: scrollTop = scrollHeight - clientHeight = 2000 - 600 = 1400
+ // content shrinks to 100; needed = 1400 + 600 = 2000; shortage = 2000 - 100 = 1900
+ expect(computeSpacerShortage(1400, 600, 100, 0)).toBe(1900)
+ })
+
+ it('handles zero-height content', () => {
+ // first streaming chunk is empty; user was at 500
+ expect(computeSpacerShortage(500, 600, 0, 0)).toBe(1100)
+ })
+
+ it('never returns a negative value', () => {
+ expect(computeSpacerShortage(0, 600, 10000, 0)).toBe(0)
+ expect(computeSpacerShortage(0, 600, 0, 0)).toBe(600)
+ })
+})
+
+describe('shouldReengage', () => {
+ it('returns false when the spacer is active, even at distanceFromBottom = 0', () => {
+ // The spacer inflates scrollHeight to exactly targetScrollTop + clientHeight,
+ // so programmatic scroll restoration always produces distanceFromBottom = 0.
+ // Without this guard, onScroll would falsely re-engage auto-follow, clear the
+ // spacer on the next content update, and jump the user to the top.
+ expect(shouldReengage(0, 1000)).toBe(false)
+ })
+
+ it('returns false when spacer is active and distance is within threshold', () => {
+ expect(shouldReengage(15, 500)).toBe(false)
+ })
+
+ it('returns false when spacer is even slightly active', () => {
+ expect(shouldReengage(0, 1)).toBe(false)
+ })
+
+ it('returns true when the user genuinely reaches the document bottom (no spacer)', () => {
+ expect(shouldReengage(0, 0)).toBe(true)
+ })
+
+ it('returns true when within threshold and spacer is cleared', () => {
+ expect(shouldReengage(30, 0)).toBe(true)
+ })
+
+ it('returns true when one pixel within threshold', () => {
+ expect(shouldReengage(29, 0)).toBe(true)
+ })
+
+ it('returns false when beyond threshold regardless of spacer', () => {
+ expect(shouldReengage(31, 0)).toBe(false)
+ expect(shouldReengage(31, 1000)).toBe(false)
+ })
+
+ it('returns false when exactly at threshold + 1', () => {
+ expect(shouldReengage(31, 0)).toBe(false)
+ })
+})
diff --git a/apps/sim/hooks/use-scroll-anchor.ts b/apps/sim/hooks/use-scroll-anchor.ts
new file mode 100644
index 0000000000..ec875eb358
--- /dev/null
+++ b/apps/sim/hooks/use-scroll-anchor.ts
@@ -0,0 +1,172 @@
+import { useCallback, useLayoutEffect, useRef } from 'react'
+
+const NEAR_BOTTOM_THRESHOLD = 30
+
+/**
+ * Returns the `minHeight` the spacer needs so `scrollTop` can safely reach
+ * `targetScrollTop` when replace-mode streaming produces temporarily shorter content.
+ */
+export function computeSpacerShortage(
+ targetScrollTop: number,
+ clientHeight: number,
+ scrollHeight: number,
+ prevSpacerHeight: number
+): number {
+ const needed = targetScrollTop + clientHeight
+ const naturalScrollHeight = scrollHeight - prevSpacerHeight
+ return Math.max(0, needed - naturalScrollHeight)
+}
+
+/**
+ * Returns whether the scroll container should re-engage auto-follow.
+ *
+ * Re-engagement is blocked while the spacer is active. The spacer inflates
+ * `scrollHeight` to exactly `targetScrollTop + clientHeight`, so programmatic
+ * scroll restoration produces `distanceFromBottom = 0` — which is artificial
+ * proximity, not the user genuinely reaching the document bottom.
+ */
+export function shouldReengage(distanceFromBottom: number, spacerHeight: number): boolean {
+ return distanceFromBottom <= NEAR_BOTTOM_THRESHOLD && spacerHeight === 0
+}
+
+/**
+ * Manages scroll for a streaming file-preview container.
+ *
+ * Never-scrolled: auto-follows new content to the bottom (MutationObserver
+ * keeps it pinned). Scrolled-up: position is locked via a spacer element that
+ * inflates `scrollHeight` to prevent the browser from clamping `scrollTop` when
+ * replace-mode streaming temporarily produces a shorter chunk. Scrolled back to
+ * the bottom: auto-follow re-engages.
+ *
+ * @param isStreaming - whether the container is currently receiving streaming content
+ * @param content - drives spacer recalculation; pass the current text value
+ */
+export function useScrollAnchor(isStreaming: boolean, content?: string) {
+ const containerRef = useRef(null)
+ const spacerRef = useRef(null)
+ const hasUserScrolledRef = useRef(false)
+ const stickyRef = useRef(false)
+ const intendedScrollTopRef = useRef(0)
+ // Avoids a layout read inside onScroll.
+ const spacerHeightRef = useRef(0)
+
+ const scrollToBottom = useCallback(() => {
+ const el = containerRef.current
+ if (!el) return
+ el.scrollTop = el.scrollHeight
+ }, [])
+
+ const onWheel = useCallback((e: WheelEvent) => {
+ if (e.deltaY >= 0 || hasUserScrolledRef.current) return
+ hasUserScrolledRef.current = true
+ stickyRef.current = false
+ const el = containerRef.current
+ if (el) intendedScrollTopRef.current = el.scrollTop
+ }, [])
+
+ const onScroll = useCallback(() => {
+ const el = containerRef.current
+ if (!el) return
+
+ if (hasUserScrolledRef.current) {
+ intendedScrollTopRef.current = el.scrollTop
+ const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
+ if (shouldReengage(distanceFromBottom, spacerHeightRef.current)) {
+ hasUserScrolledRef.current = false
+ stickyRef.current = true
+ }
+ return
+ }
+
+ const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
+ if (distanceFromBottom > NEAR_BOTTOM_THRESHOLD) {
+ hasUserScrolledRef.current = true
+ stickyRef.current = false
+ intendedScrollTopRef.current = el.scrollTop
+ } else {
+ stickyRef.current = true
+ }
+ }, [])
+
+ const callbackRef = useCallback(
+ (el: HTMLDivElement | null) => {
+ const prev = containerRef.current
+ if (prev) {
+ prev.removeEventListener('scroll', onScroll)
+ prev.removeEventListener('wheel', onWheel as EventListener)
+ }
+ containerRef.current = el
+ if (el) {
+ el.addEventListener('scroll', onScroll, { passive: true })
+ el.addEventListener('wheel', onWheel as EventListener, { passive: true })
+ }
+ },
+ [onScroll, onWheel]
+ )
+
+ useLayoutEffect(() => {
+ if (!isStreaming) return
+ const el = containerRef.current
+ if (!el) return
+ if (hasUserScrolledRef.current) return
+ const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
+ stickyRef.current = distanceFromBottom <= NEAR_BOTTOM_THRESHOLD
+ if (stickyRef.current) scrollToBottom()
+ }, [isStreaming, scrollToBottom])
+
+ useLayoutEffect(() => {
+ if (!isStreaming) return
+ const el = containerRef.current
+ if (!el) return
+
+ let rafId = 0
+ const guardedScroll = () => {
+ if (stickyRef.current) scrollToBottom()
+ }
+ const onMutation = () => {
+ if (!stickyRef.current) return
+ cancelAnimationFrame(rafId)
+ rafId = requestAnimationFrame(guardedScroll)
+ }
+
+ const observer = new MutationObserver(onMutation)
+ observer.observe(el, { childList: true, subtree: true, characterData: true })
+
+ return () => {
+ observer.disconnect()
+ cancelAnimationFrame(rafId)
+ }
+ }, [isStreaming, scrollToBottom])
+
+ useLayoutEffect(() => {
+ const el = containerRef.current
+ const spacer = spacerRef.current
+ if (!el) return
+
+ if (!hasUserScrolledRef.current || !isStreaming) {
+ spacerHeightRef.current = 0
+ if (spacer) spacer.style.minHeight = '0'
+ return
+ }
+
+ // Must read before scrollHeight: that forced reflow can synchronously fire 'scroll' and clamp the value.
+ const targetScrollTop = intendedScrollTopRef.current
+
+ const prevSpacerHeight = spacer ? spacer.offsetHeight : 0
+ const shortage = computeSpacerShortage(
+ targetScrollTop,
+ el.clientHeight,
+ el.scrollHeight,
+ prevSpacerHeight
+ )
+
+ spacerHeightRef.current = shortage
+ if (spacer) spacer.style.minHeight = `${shortage}px`
+ if (el.scrollTop < targetScrollTop) el.scrollTop = targetScrollTop
+ }, [content, isStreaming])
+
+ return {
+ ref: callbackRef,
+ spacerRef,
+ }
+}