Skip to content

Commit db84d87

Browse files
committed
fix(file-viewer): prevent scroll jump to top during Mothership streaming
- Fix root cause: MarkdownCheckboxCtx.Provider was conditionally rendered, causing the scroll container to unmount/remount when isStreaming flipped, resetting scrollTop to 0 on every stream start - Add useScrollAnchor hook with spacer element to preserve scroll position when streamed content temporarily shrinks the scroll container - Linger active session ID on complete to prevent streamingContent→undefined flicker between consecutive tool calls on the same file - Gate upsert activation on incoming session having renderable content - Fix shouldShowStreamingFilePanel to keep panel mounted during linger - Fix use-chat post-write navigation to work with lingered completed session - Fix useAutoScroll to check proximity before pinning to bottom on stream start
1 parent 8774f5c commit db84d87

9 files changed

Lines changed: 742 additions & 27 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import 'prismjs/components/prism-python'
3434
import { cn } from '@/lib/core/utils/cn'
3535
import { extractTextContent } from '@/lib/core/utils/react-node-text'
3636
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
37-
import { useAutoScroll } from '@/hooks/use-auto-scroll'
37+
import { useScrollAnchor } from '@/hooks/use-scroll-anchor'
3838
import { DataTable } from './data-table'
3939
import { ZoomablePreview } from './zoomable-preview'
4040

@@ -866,7 +866,10 @@ const MarkdownPreview = memo(function MarkdownPreview({
866866
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
867867
}) {
868868
const { push: navigate } = useRouter()
869-
const { ref: autoScrollRef } = useAutoScroll(isStreaming && !disableAutoScroll)
869+
const { ref: autoScrollRef, spacerRef } = useScrollAnchor(
870+
isStreaming && !disableAutoScroll,
871+
content
872+
)
870873

871874
const contentRef = useRef(content)
872875
contentRef.current = content
@@ -921,16 +924,13 @@ const MarkdownPreview = memo(function MarkdownPreview({
921924
>
922925
{markdownContent}
923926
</Streamdown>
927+
<div ref={spacerRef} aria-hidden />
924928
</div>
925929
)
926930

927931
return (
928932
<NavigateCtx.Provider value={navigate}>
929-
{onCheckboxToggle ? (
930-
<MarkdownCheckboxCtx.Provider value={ctxValue}>{body}</MarkdownCheckboxCtx.Provider>
931-
) : (
932-
body
933-
)}
933+
<MarkdownCheckboxCtx.Provider value={ctxValue}>{body}</MarkdownCheckboxCtx.Provider>
934934
</NavigateCtx.Provider>
935935
)
936936
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,3 +337,112 @@ describe('syncTextEditorContentState — streaming finalize shortcuts', () => {
337337
expect(next.content).toBe('v2')
338338
})
339339
})
340+
341+
describe('syncTextEditorContentState — inter-session content shrink (replace mode)', () => {
342+
// These tests cover the path that causes scroll jumps in the file viewer.
343+
// During "session linger" (session 1 complete, session 2 not yet started),
344+
// the state machine stays in 'streaming' phase with session 1's full content.
345+
// When session 2's first chunk arrives it is often SHORTER than session 1's
346+
// final content. The state machine must correctly replace the content rather
347+
// than treating it as already-final or dropping it.
348+
349+
it('replaces long linger content with a short first chunk from a new session', () => {
350+
// State during linger: streaming with session 1's full long content
351+
const lingerState = streaming(
352+
'a very long document with many paragraphs',
353+
'a very long document with many paragraphs',
354+
''
355+
)
356+
// Session 2 first chunk arrives — much shorter than session 1's content
357+
const next = syncTextEditorContentState(lingerState, {
358+
canReconcileToFetchedContent: false,
359+
fetchedContent: undefined,
360+
streamingContent: 'short',
361+
streamingMode: 'replace',
362+
})
363+
expect(next.phase).toBe('streaming')
364+
expect(next.content).toBe('short')
365+
expect(next.lastStreamedContent).toBe('short')
366+
})
367+
368+
it('correctly transitions to the new chunk even when it is a single character', () => {
369+
const lingerState = streaming(
370+
'full document\nmany lines\nof content',
371+
'full document\nmany lines\nof content',
372+
''
373+
)
374+
const next = syncTextEditorContentState(lingerState, {
375+
canReconcileToFetchedContent: false,
376+
fetchedContent: undefined,
377+
streamingContent: '#',
378+
streamingMode: 'replace',
379+
})
380+
expect(next.phase).toBe('streaming')
381+
expect(next.content).toBe('#')
382+
})
383+
384+
it('does not finalize early when the new short chunk happens to equal savedContent', () => {
385+
// savedContent '' and streamingContent '' — but we are in streaming phase,
386+
// not ready, so content should remain in streaming phase
387+
const lingerState = streaming('long content', 'long content', 'old saved')
388+
const next = syncTextEditorContentState(lingerState, {
389+
canReconcileToFetchedContent: false,
390+
fetchedContent: undefined,
391+
streamingContent: '',
392+
streamingMode: 'replace',
393+
})
394+
expect(next.phase).toBe('streaming')
395+
expect(next.content).toBe('')
396+
})
397+
398+
it('stays streaming across multiple growing chunks after the shrink', () => {
399+
const lingerState = streaming('final long document', 'final long document', '')
400+
401+
const chunk1 = syncTextEditorContentState(lingerState, {
402+
canReconcileToFetchedContent: false,
403+
fetchedContent: undefined,
404+
streamingContent: '# New',
405+
streamingMode: 'replace',
406+
})
407+
expect(chunk1.phase).toBe('streaming')
408+
expect(chunk1.content).toBe('# New')
409+
410+
const chunk2 = syncTextEditorContentState(chunk1, {
411+
canReconcileToFetchedContent: false,
412+
fetchedContent: undefined,
413+
streamingContent: '# New Section\n\nSome text',
414+
streamingMode: 'replace',
415+
})
416+
expect(chunk2.phase).toBe('streaming')
417+
expect(chunk2.content).toBe('# New Section\n\nSome text')
418+
419+
const chunk3 = syncTextEditorContentState(chunk2, {
420+
canReconcileToFetchedContent: false,
421+
fetchedContent: undefined,
422+
streamingContent: '# New Section\n\nSome text that is now longer than the original',
423+
streamingMode: 'replace',
424+
})
425+
expect(chunk3.phase).toBe('streaming')
426+
expect(chunk3.content).toBe('# New Section\n\nSome text that is now longer than the original')
427+
})
428+
429+
it('synthetic file (canReconcile=false) finalizes with current content when streaming ends', () => {
430+
// After the new session finishes streaming, since there is no fetchedContent
431+
// to reconcile with (synthetic file has no db key), it must finalize immediately.
432+
const finalChunk = streaming(
433+
'# Complete Document\n\nAll done.',
434+
'# Complete Document\n\nAll done.',
435+
''
436+
)
437+
const next = syncTextEditorContentState(finalChunk, {
438+
canReconcileToFetchedContent: false,
439+
fetchedContent: undefined,
440+
streamingContent: undefined,
441+
streamingMode: 'replace',
442+
})
443+
expect(next.phase).toBe('ready')
444+
expect(next.content).toBe('# Complete Document\n\nAll done.')
445+
expect(next.savedContent).toBe('# Complete Document\n\nAll done.')
446+
expect(next.lastStreamedContent).toBeNull()
447+
})
448+
})

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,11 @@ function shouldShowStreamingFilePanel(
3030
previewSession: FilePreviewSession | null | undefined,
3131
active: MothershipResource | null
3232
): boolean {
33-
if (!previewSession || previewSession.status === 'complete' || !active) return false
33+
if (!previewSession || !hasRenderableFilePreviewContent(previewSession) || !active) return false
3434
if (active.id === 'streaming-file') return true
3535
if (active.type !== 'file') return false
3636
if (active.id && previewSession.fileId === active.id) {
37-
return hasRenderableFilePreviewContent(previewSession)
37+
return true
3838
}
3939
return false
4040
}

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3402,7 +3402,11 @@ export function useChat(
34023402
if (parentToolCallId) {
34033403
subagentByParentToolCallId.delete(parentToolCallId)
34043404
}
3405-
if (previewSessionRef.current && !activePreviewSessionIdRef.current) {
3405+
if (
3406+
previewSessionRef.current &&
3407+
(!activePreviewSessionIdRef.current ||
3408+
previewSessionRef.current.status === 'complete')
3409+
) {
34063410
const lastFileResource = resourcesRef.current.find(
34073411
(r) => r.type === 'file' && r.id !== 'streaming-file'
34083412
)

0 commit comments

Comments
 (0)