Merge pull request #747 from buster-so/devin/1755829561-auto-scroll-report-editor

feat: integrate useAutoScroll hook with ReportEditor for streaming content
This commit is contained in:
Nate Kelley 2025-08-21 22:22:09 -06:00 committed by GitHub
commit 6b0e473da9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 68 additions and 43 deletions

View File

@ -3,18 +3,20 @@ import { ChevronDown } from '@/components/ui/icons';
import { AppTooltip } from '@/components/ui/tooltip'; import { AppTooltip } from '@/components/ui/tooltip';
import { cn } from '@/lib/classMerge'; import { cn } from '@/lib/classMerge';
export const ReasoningScrollToBottom: React.FC<{ export const ScrollToBottomButton: React.FC<{
isAutoScrollEnabled: boolean; isAutoScrollEnabled: boolean;
scrollToBottom: () => void; scrollToBottom: () => void;
}> = React.memo(({ isAutoScrollEnabled, scrollToBottom }) => { className?: string;
}> = React.memo(({ isAutoScrollEnabled, scrollToBottom, className }) => {
return ( return (
<div <div
data-testid="reasoning-scroll-to-bottom" data-testid="scroll-to-bottom-button"
className={cn( className={cn(
'absolute right-4 bottom-4 z-10 duration-300', 'absolute right-4 bottom-4 z-10 duration-300',
isAutoScrollEnabled isAutoScrollEnabled
? 'pointer-events-none scale-90 opacity-0' ? 'pointer-events-none scale-90 opacity-0'
: 'pointer-events-auto scale-100 cursor-pointer opacity-100' : 'pointer-events-auto scale-100 cursor-pointer opacity-100',
className
)}> )}>
<AppTooltip title="Stick to bottom" sideOffset={12} delayDuration={500}> <AppTooltip title="Stick to bottom" sideOffset={12} delayDuration={500}>
<button <button
@ -30,4 +32,4 @@ export const ReasoningScrollToBottom: React.FC<{
); );
}); });
ReasoningScrollToBottom.displayName = 'ReasoningScrollToBottom'; ScrollToBottomButton.displayName = 'ScrollToBottomButton';

View File

@ -1,6 +1,7 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { PlateContainer } from 'platejs/react'; import { PlateContainer } from 'platejs/react';
import { cva, type VariantProps } from 'class-variance-authority'; import { cva, type VariantProps } from 'class-variance-authority';
import React from 'react';
interface EditorContainerProps { interface EditorContainerProps {
className?: string; className?: string;
@ -37,22 +38,24 @@ const editorContainerVariants = cva(
} }
); );
export function EditorContainer({ export const EditorContainer = React.forwardRef<
className, HTMLDivElement,
variant, React.HTMLAttributes<HTMLDivElement> &
readOnly, VariantProps<typeof editorContainerVariants> &
...props EditorContainerProps
}: React.ComponentProps<'div'> & >(({ className, variant, readOnly, children, ...htmlProps }, ref) => {
VariantProps<typeof editorContainerVariants> &
EditorContainerProps) {
return ( return (
<PlateContainer <div
ref={ref}
className={cn( className={cn(
'ignore-click-outside/toolbar', 'ignore-click-outside/toolbar',
editorContainerVariants({ variant, readOnly }), editorContainerVariants({ variant, readOnly }),
className className
)} )}
{...props} {...htmlProps}>
/> <PlateContainer>{children}</PlateContainer>
</div>
); );
} });
EditorContainer.displayName = 'EditorContainer';

View File

@ -2,8 +2,9 @@
import type { Value, AnyPluginConfig } from 'platejs'; import type { Value, AnyPluginConfig } from 'platejs';
import { Plate, type TPlateEditor } from 'platejs/react'; import { Plate, type TPlateEditor } from 'platejs/react';
import React, { useImperativeHandle, useRef } from 'react'; import React, { useEffect, useImperativeHandle, useRef } from 'react';
import { useDebounceFn, useMemoizedFn } from '@/hooks'; import { useDebounceFn, useMemoizedFn } from '@/hooks';
import { useAutoScroll } from '@/hooks/useAutoScroll';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Editor } from './Editor'; import { Editor } from './Editor';
import { EditorContainer } from './EditorContainer'; import { EditorContainer } from './EditorContainer';
@ -12,6 +13,7 @@ import { useReportEditor } from './useReportEditor';
import type { ReportElementsWithIds, ReportElementWithId } from '@buster/server-shared/reports'; import type { ReportElementsWithIds, ReportElementWithId } from '@buster/server-shared/reports';
import { platejsToMarkdown } from './plugins/markdown-kit/platejs-conversions'; import { platejsToMarkdown } from './plugins/markdown-kit/platejs-conversions';
import { ShimmerText } from '@/components/ui/typography/ShimmerText'; import { ShimmerText } from '@/components/ui/typography/ShimmerText';
import { ScrollToBottomButton } from '../buttons/ScrollToBottomButton';
interface ReportEditorProps { interface ReportEditorProps {
// We accept the generic Value type but recommend using ReportTypes.Value for type safety // We accept the generic Value type but recommend using ReportTypes.Value for type safety
@ -29,7 +31,7 @@ interface ReportEditorProps {
onReady?: (editor: IReportEditor) => void; onReady?: (editor: IReportEditor) => void;
id?: string; id?: string;
mode?: 'export' | 'default'; mode?: 'export' | 'default';
children?: React.ReactNode; preEditorChildren?: React.ReactNode;
postEditorChildren?: React.ReactNode; postEditorChildren?: React.ReactNode;
} }
@ -59,13 +61,21 @@ export const ReportEditor = React.memo(
useFixedToolbarKit = false, useFixedToolbarKit = false,
readOnly = false, readOnly = false,
isStreaming = false, isStreaming = false,
children, preEditorChildren,
postEditorChildren postEditorChildren
}, },
ref ref
) => { ) => {
// Initialize the editor instance using the custom useEditor hook // Initialize the editor instance using the custom useEditor hook
const isReady = useRef(false); const isReady = useRef(false);
const editorContainerRef = useRef<HTMLDivElement>(null);
const { isAutoScrollEnabled, enableAutoScroll, disableAutoScroll, scrollToBottom } =
useAutoScroll(editorContainerRef, {
enabled: isStreaming,
bottomThreshold: 50,
observeSubTree: true
});
const editor = useReportEditor({ const editor = useReportEditor({
isStreaming, isStreaming,
@ -118,15 +128,24 @@ export const ReportEditor = React.memo(
wait: 1500 wait: 1500
}); });
useEffect(() => {
if (isStreaming) {
enableAutoScroll();
} else {
disableAutoScroll();
}
}, [isStreaming]);
if (!editor) return null; if (!editor) return null;
return ( return (
<Plate editor={editor} onValueChange={onValueChangeDebounced}> <Plate editor={editor} onValueChange={onValueChangeDebounced}>
<EditorContainer <EditorContainer
ref={editorContainerRef}
variant={variant} variant={variant}
readOnly={readOnly} readOnly={readOnly}
className={cn('editor-container relative overflow-auto', containerClassName)}> className={cn('editor-container relative overflow-auto', containerClassName)}>
{children} {preEditorChildren}
<ThemeWrapper id={id}> <ThemeWrapper id={id}>
<Editor <Editor
style={style} style={style}
@ -137,6 +156,13 @@ export const ReportEditor = React.memo(
/> />
</ThemeWrapper> </ThemeWrapper>
{postEditorChildren} {postEditorChildren}
{isStreaming && (
<ScrollToBottomButton
isAutoScrollEnabled={isAutoScrollEnabled}
scrollToBottom={scrollToBottom}
className="fixed right-8 bottom-8 z-10"
/>
)}
</EditorContainer> </EditorContainer>
</Plate> </Plate>
); );

View File

@ -15,7 +15,7 @@ export function StreamingText(props: PlateTextProps) {
<PlateText <PlateText
className={cn( className={cn(
'streaming-node', 'streaming-node',
isStreaming && ['animate-highlight-fade'], isStreaming && 'animate-highlight-fade',
// Only show the animated dot on the last streaming text node // Only show the animated dot on the last streaming text node
isLastStreamingText && [ isLastStreamingText && [
'after:ml-1.5 after:inline-block after:h-3 after:w-3 after:animate-pulse after:rounded-full after:bg-purple-500 after:align-middle after:content-[""]' 'after:ml-1.5 after:inline-block after:h-3 after:w-3 after:animate-pulse after:rounded-full after:bg-purple-500 after:align-middle after:content-[""]'

View File

@ -11,7 +11,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { useAutoScroll } from '@/hooks/useAutoScroll'; import { useAutoScroll } from '@/hooks/useAutoScroll';
import { ReasoningMessageSelector } from './ReasoningMessages'; import { ReasoningMessageSelector } from './ReasoningMessages';
import { BlackBoxMessage } from './ReasoningMessages/ReasoningBlackBoxMessage'; import { BlackBoxMessage } from './ReasoningMessages/ReasoningBlackBoxMessage';
import { ReasoningScrollToBottom } from './ReasoningScrollToBottom'; import { ScrollToBottomButton } from '@/components/ui/buttons/ScrollToBottomButton';
import type { BusterChatMessage, IBusterChat } from '@/api/asset_interfaces/chat'; import type { BusterChatMessage, IBusterChat } from '@/api/asset_interfaces/chat';
interface ReasoningControllerProps { interface ReasoningControllerProps {
@ -79,7 +79,7 @@ export const ReasoningController: React.FC<ReasoningControllerProps> = ({ chatId
</div> </div>
</ScrollArea> </ScrollArea>
<ReasoningScrollToBottom <ScrollToBottomButton
isAutoScrollEnabled={isAutoScrollEnabled} isAutoScrollEnabled={isAutoScrollEnabled}
scrollToBottom={scrollToBottom} scrollToBottom={scrollToBottom}
/> />

View File

@ -10,9 +10,7 @@ import { type IReportEditor } from '@/components/ui/report/ReportEditor';
import { ReportEditorSkeleton } from '@/components/ui/report/ReportEditorSkeleton'; import { ReportEditorSkeleton } from '@/components/ui/report/ReportEditorSkeleton';
import { useChatIndividualContextSelector } from '@/layouts/ChatLayout/ChatContext'; import { useChatIndividualContextSelector } from '@/layouts/ChatLayout/ChatContext';
import { useTrackAndUpdateReportChanges } from '@/api/buster-electric/reports/hooks'; import { useTrackAndUpdateReportChanges } from '@/api/buster-electric/reports/hooks';
import { ShimmerText } from '@/components/ui/typography/ShimmerText';
import { GeneratingContent } from './GeneratingContent'; import { GeneratingContent } from './GeneratingContent';
import { useHotkeys } from 'react-hotkeys-hook';
export const ReportPageController: React.FC<{ export const ReportPageController: React.FC<{
reportId: string; reportId: string;
@ -73,19 +71,20 @@ export const ReportPageController: React.FC<{
mode={mode} mode={mode}
onReady={onReadyProp} onReady={onReadyProp}
isStreaming={isStreamingMessage} isStreaming={isStreamingMessage}
preEditorChildren={
<ReportPageHeader
name={report?.name}
updatedAt={report?.updated_at}
onChangeName={onChangeName}
className={commonClassName}
isStreaming={isStreamingMessage}
/>
}
postEditorChildren={ postEditorChildren={
showGeneratingContent ? ( showGeneratingContent ? (
<GeneratingContent messageId={messageId} className={commonClassName} /> <GeneratingContent messageId={messageId} className={commonClassName} />
) : null ) : null
}> }></DynamicReportEditor>
<ReportPageHeader
name={report?.name}
updatedAt={report?.updated_at}
onChangeName={onChangeName}
className={commonClassName}
isStreaming={isStreamingMessage}
/>
</DynamicReportEditor>
) : ( ) : (
<ReportEditorSkeleton /> <ReportEditorSkeleton />
)} )}

View File

@ -60,21 +60,16 @@
} }
} }
/*
highlightFade animation:
- Animates a highlight background and a bottom border only (no outline).
- The border is only on the bottom, not using outline.
*/
@keyframes highlightFade { @keyframes highlightFade {
0% { 0% {
/* Use highlight background, fallback to brand, then yellow */ /* Use highlight background, fallback to brand, then yellow */
background-color: var(--color-highlight-background, var(--color-purple-100, yellow)); background-color: var(--color-highlight-background, var(--color-purple-100, yellow));
/* Only bottom border is visible at start */ /* Use box-shadow instead of border - doesn't take up space */
border-bottom: 1px solid var(--color-highlight-border, var(--color-purple-200, yellow)); box-shadow: 0 1.5px 0 0 var(--color-highlight-border, var(--color-purple-300, yellow));
} }
100% { 100% {
background-color: var(--color-highlight-to-background, transparent); background-color: var(--color-highlight-to-background, transparent);
border-bottom: 0px solid var(--color-highlight-to-border, transparent); box-shadow: 0 0 0 0 var(--color-highlight-to-border, transparent);
} }
} }