mirror of https://github.com/buster-so/buster.git
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:
commit
6b0e473da9
|
@ -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';
|
|
@ -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,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<'div'> &
|
|
||||||
VariantProps<typeof editorContainerVariants> &
|
VariantProps<typeof editorContainerVariants> &
|
||||||
EditorContainerProps) {
|
EditorContainerProps
|
||||||
|
>(({ className, variant, readOnly, children, ...htmlProps }, ref) => {
|
||||||
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';
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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-[""]'
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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,11 +71,7 @@ export const ReportPageController: React.FC<{
|
||||||
mode={mode}
|
mode={mode}
|
||||||
onReady={onReadyProp}
|
onReady={onReadyProp}
|
||||||
isStreaming={isStreamingMessage}
|
isStreaming={isStreamingMessage}
|
||||||
postEditorChildren={
|
preEditorChildren={
|
||||||
showGeneratingContent ? (
|
|
||||||
<GeneratingContent messageId={messageId} className={commonClassName} />
|
|
||||||
) : null
|
|
||||||
}>
|
|
||||||
<ReportPageHeader
|
<ReportPageHeader
|
||||||
name={report?.name}
|
name={report?.name}
|
||||||
updatedAt={report?.updated_at}
|
updatedAt={report?.updated_at}
|
||||||
|
@ -85,7 +79,12 @@ export const ReportPageController: React.FC<{
|
||||||
className={commonClassName}
|
className={commonClassName}
|
||||||
isStreaming={isStreamingMessage}
|
isStreaming={isStreamingMessage}
|
||||||
/>
|
/>
|
||||||
</DynamicReportEditor>
|
}
|
||||||
|
postEditorChildren={
|
||||||
|
showGeneratingContent ? (
|
||||||
|
<GeneratingContent messageId={messageId} className={commonClassName} />
|
||||||
|
) : null
|
||||||
|
}></DynamicReportEditor>
|
||||||
) : (
|
) : (
|
||||||
<ReportEditorSkeleton />
|
<ReportEditorSkeleton />
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue