mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
♻️ refactor: replace per-message useNewScreen with centralized useConversationSpacer (#13042)
* ♻️ refactor: replace per-message useNewScreen with centralized useConversationSpacer Replace the old per-message min-height approach with a single spacer element appended to the virtual list, simplifying scroll-to-top UX when user sends a new message. * 🔧 refactor: streamline handleSendButton logic and enhance editor focus behavior Removed redundant editor null check and added double requestAnimationFrame calls to ensure the editor is focused after sending a message. Signed-off-by: Innei <tukon479@gmail.com> --------- Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -78,7 +78,7 @@ export function viteEnvRestartKeys(keys: string[]): Plugin {
|
|||||||
return {
|
return {
|
||||||
server: {
|
server: {
|
||||||
watch: {
|
watch: {
|
||||||
ignored: ['**/.env', '**/.env.*'],
|
ignored: ['**/.env', '**/.env.*', '**/*.test.ts', '**/*.test.tsx'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,9 +32,8 @@ export const store: CreateStore = (publicState) => (set, get) => ({
|
|||||||
return String(get().editor?.getDocument('markdown') || '').trimEnd();
|
return String(get().editor?.getDocument('markdown') || '').trimEnd();
|
||||||
},
|
},
|
||||||
handleSendButton: () => {
|
handleSendButton: () => {
|
||||||
if (!get().editor) return;
|
|
||||||
|
|
||||||
const editor = get().editor;
|
const editor = get().editor;
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
get().onSend?.({
|
get().onSend?.({
|
||||||
clearContent: () => editor?.cleanDocument(),
|
clearContent: () => editor?.cleanDocument(),
|
||||||
@@ -42,6 +41,11 @@ export const store: CreateStore = (publicState) => (set, get) => ({
|
|||||||
getEditorData: get().getJSONState,
|
getEditorData: get().getJSONState,
|
||||||
getMarkdownContent: get().getMarkdownContent,
|
getMarkdownContent: get().getMarkdownContent,
|
||||||
});
|
});
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
editor.focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
handleStop: () => {
|
handleStop: () => {
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ const ChatItem = memo<ChatItemProps>(
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
id,
|
id,
|
||||||
style,
|
style,
|
||||||
newScreenMinHeight,
|
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
const isUser = placement === 'right';
|
const isUser = placement === 'right';
|
||||||
@@ -68,7 +67,6 @@ const ChatItem = memo<ChatItemProps>(
|
|||||||
gap={8}
|
gap={8}
|
||||||
paddingBlock={8}
|
paddingBlock={8}
|
||||||
style={{
|
style={{
|
||||||
minHeight: newScreenMinHeight,
|
|
||||||
paddingInlineStart: isUser ? 36 : 0,
|
paddingInlineStart: isUser ? 36 : 0,
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -36,9 +36,8 @@ export interface ChatItemProps extends Omit<FlexboxProps, 'children' | 'onChange
|
|||||||
message?: ReactNode;
|
message?: ReactNode;
|
||||||
messageExtra?: ReactNode;
|
messageExtra?: ReactNode;
|
||||||
/**
|
/**
|
||||||
* Dynamic min-height for new screen effect, e.g. "calc(100dvh - 350px)"
|
* Avatar click handler
|
||||||
*/
|
*/
|
||||||
newScreenMinHeight?: string;
|
|
||||||
onAvatarClick?: () => void;
|
onAvatarClick?: () => void;
|
||||||
onDoubleClick?: DivProps['onDoubleClick'];
|
onDoubleClick?: DivProps['onDoubleClick'];
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
useConversationStore,
|
useConversationStore,
|
||||||
virtuaListSelectors,
|
virtuaListSelectors,
|
||||||
} from '../../../store';
|
} from '../../../store';
|
||||||
import { useAutoScrollEnabled } from './useAutoScrollEnabled';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AutoScroll component - handles auto-scrolling logic during AI generation.
|
* AutoScroll component - handles auto-scrolling logic during AI generation.
|
||||||
@@ -23,9 +22,8 @@ const AutoScroll = memo(() => {
|
|||||||
const isGenerating = useConversationStore(messageStateSelectors.isAIGenerating);
|
const isGenerating = useConversationStore(messageStateSelectors.isAIGenerating);
|
||||||
const scrollToBottom = useConversationStore((s) => s.scrollToBottom);
|
const scrollToBottom = useConversationStore((s) => s.scrollToBottom);
|
||||||
const dbMessages = useConversationStore(dataSelectors.dbMessages);
|
const dbMessages = useConversationStore(dataSelectors.dbMessages);
|
||||||
const isAutoScrollEnabled = useAutoScrollEnabled();
|
|
||||||
|
|
||||||
const shouldAutoScroll = isAutoScrollEnabled && atBottom && isGenerating && !isScrolling;
|
const shouldAutoScroll = atBottom && isGenerating && !isScrolling;
|
||||||
|
|
||||||
// Get the content length of the last message to monitor streaming output
|
// Get the content length of the last message to monitor streaming output
|
||||||
const lastMessage = dbMessages.at(-1);
|
const lastMessage = dbMessages.at(-1);
|
||||||
|
|||||||
@@ -8,10 +8,15 @@ import { VList } from 'virtua';
|
|||||||
|
|
||||||
import WideScreenContainer from '../../../WideScreenContainer';
|
import WideScreenContainer from '../../../WideScreenContainer';
|
||||||
import { dataSelectors, useConversationStore, virtuaListSelectors } from '../../store';
|
import { dataSelectors, useConversationStore, virtuaListSelectors } from '../../store';
|
||||||
|
import {
|
||||||
|
CONVERSATION_SPACER_TRANSITION_MS,
|
||||||
|
useConversationSpacer,
|
||||||
|
} from '../hooks/useConversationSpacer';
|
||||||
import { useScrollToUserMessage } from '../hooks/useScrollToUserMessage';
|
import { useScrollToUserMessage } from '../hooks/useScrollToUserMessage';
|
||||||
import AutoScroll from './AutoScroll';
|
import AutoScroll from './AutoScroll';
|
||||||
import { AT_BOTTOM_THRESHOLD } from './AutoScroll/const';
|
import { AT_BOTTOM_THRESHOLD } from './AutoScroll/const';
|
||||||
import DebugInspector, { OPEN_DEV_INSPECTOR } from './AutoScroll/DebugInspector';
|
import DebugInspector, { OPEN_DEV_INSPECTOR } from './AutoScroll/DebugInspector';
|
||||||
|
import { useAutoScrollEnabled } from './AutoScroll/useAutoScrollEnabled';
|
||||||
import BackBottom from './BackBottom';
|
import BackBottom from './BackBottom';
|
||||||
|
|
||||||
interface VirtualizedListProps {
|
interface VirtualizedListProps {
|
||||||
@@ -27,6 +32,9 @@ interface VirtualizedListProps {
|
|||||||
const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent }) => {
|
const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent }) => {
|
||||||
const virtuaRef = useRef<VListHandle>(null);
|
const virtuaRef = useRef<VListHandle>(null);
|
||||||
const scrollEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const scrollEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const { isSpacerMessage, listData, spacerHeight, spacerActive } =
|
||||||
|
useConversationSpacer(dataSource);
|
||||||
|
const isAutoScrollEnabled = useAutoScrollEnabled();
|
||||||
|
|
||||||
// Store actions
|
// Store actions
|
||||||
const registerVirtuaScrollMethods = useConversationStore((s) => s.registerVirtuaScrollMethods);
|
const registerVirtuaScrollMethods = useConversationStore((s) => s.registerVirtuaScrollMethods);
|
||||||
@@ -85,9 +93,12 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
|
|||||||
const ref = virtuaRef.current;
|
const ref = virtuaRef.current;
|
||||||
if (ref) {
|
if (ref) {
|
||||||
registerVirtuaScrollMethods({
|
registerVirtuaScrollMethods({
|
||||||
|
getItemOffset: (index) => ref.getItemOffset(index),
|
||||||
|
getItemSize: (index) => ref.getItemSize(index),
|
||||||
getScrollOffset: () => ref.scrollOffset,
|
getScrollOffset: () => ref.scrollOffset,
|
||||||
getScrollSize: () => ref.scrollSize,
|
getScrollSize: () => ref.scrollSize,
|
||||||
getViewportSize: () => ref.viewportSize,
|
getViewportSize: () => ref.viewportSize,
|
||||||
|
scrollTo: (offset) => ref.scrollTo(offset),
|
||||||
scrollToIndex: (index, options) => ref.scrollToIndex(index, options),
|
scrollToIndex: (index, options) => ref.scrollToIndex(index, options),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,13 +154,29 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
|
|||||||
{OPEN_DEV_INSPECTOR && <DebugInspector />}
|
{OPEN_DEV_INSPECTOR && <DebugInspector />}
|
||||||
<VList
|
<VList
|
||||||
bufferSize={typeof window !== 'undefined' ? window.innerHeight : 0}
|
bufferSize={typeof window !== 'undefined' ? window.innerHeight : 0}
|
||||||
data={dataSource}
|
data={listData}
|
||||||
ref={virtuaRef}
|
ref={virtuaRef}
|
||||||
style={{ height: '100%', overflowAnchor: 'none', paddingBottom: 24 }}
|
style={{ height: '100%', overflowAnchor: 'none', paddingBottom: 24 }}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
onScrollEnd={handleScrollEnd}
|
onScrollEnd={handleScrollEnd}
|
||||||
>
|
>
|
||||||
{(messageId, index): ReactElement => {
|
{(messageId, index): ReactElement => {
|
||||||
|
if (isSpacerMessage(messageId)) {
|
||||||
|
return (
|
||||||
|
<WideScreenContainer key={messageId} style={{ position: 'relative' }}>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
height: spacerHeight,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
transition: `height ${CONVERSATION_SPACER_TRANSITION_MS}ms ease`,
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</WideScreenContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const isAgentCouncil = messageId.includes('agentCouncil');
|
const isAgentCouncil = messageId.includes('agentCouncil');
|
||||||
const isLastItem = index === dataSource.length - 1;
|
const isLastItem = index === dataSource.length - 1;
|
||||||
const content = itemContent(index, messageId);
|
const content = itemContent(index, messageId);
|
||||||
@@ -160,7 +187,7 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
|
|||||||
<div key={messageId} style={{ position: 'relative', width: '100%' }}>
|
<div key={messageId} style={{ position: 'relative', width: '100%' }}>
|
||||||
{content}
|
{content}
|
||||||
{/* AutoScroll is placed inside the last Item so it only triggers when the last Item is visible */}
|
{/* AutoScroll is placed inside the last Item so it only triggers when the last Item is visible */}
|
||||||
{isLastItem && <AutoScroll />}
|
{isLastItem && isAutoScrollEnabled && !spacerActive && <AutoScroll />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -168,8 +195,7 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
|
|||||||
return (
|
return (
|
||||||
<WideScreenContainer key={messageId} style={{ position: 'relative' }}>
|
<WideScreenContainer key={messageId} style={{ position: 'relative' }}>
|
||||||
{content}
|
{content}
|
||||||
{/* AutoScroll is placed inside the last Item so it only triggers when the last Item is visible */}
|
{isLastItem && isAutoScrollEnabled && !spacerActive && <AutoScroll />}
|
||||||
{isLastItem && <AutoScroll />}
|
|
||||||
</WideScreenContainer>
|
</WideScreenContainer>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { calculateConversationSpacerHeight, CONVERSATION_SPACER_ID } from './useConversationSpacer';
|
||||||
|
|
||||||
|
describe('useConversationSpacer helpers', () => {
|
||||||
|
it('should calculate the remaining spacer height behind the latest assistant message', () => {
|
||||||
|
expect(calculateConversationSpacerHeight(800, 200, 80)).toBe(520);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clamp spacer height to zero when content already fills the viewport', () => {
|
||||||
|
expect(calculateConversationSpacerHeight(800, 300, 600)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep the reserved spacer id stable', () => {
|
||||||
|
expect(CONVERSATION_SPACER_ID).toBe('__conversation_spacer__');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { type AssistantContentBlock, type UIChatMessage } from '@lobechat/types';
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { dataSelectors, messageStateSelectors, useConversationStore } from '../../store';
|
||||||
|
|
||||||
|
export const CONVERSATION_SPACER_ID = '__conversation_spacer__';
|
||||||
|
export const CONVERSATION_SPACER_TRANSITION_MS = 200;
|
||||||
|
|
||||||
|
export const calculateConversationSpacerHeight = (
|
||||||
|
viewportHeight: number,
|
||||||
|
userHeight: number,
|
||||||
|
assistantHeight: number,
|
||||||
|
) => Math.max(Math.round(viewportHeight - userHeight - assistantHeight), 0);
|
||||||
|
|
||||||
|
const getMessageElement = (messageId: string | null) => {
|
||||||
|
if (!messageId) return null;
|
||||||
|
|
||||||
|
return document.querySelector(`[data-message-id="${messageId}"]`) as HTMLElement | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMessageHeight = (messageId: string | null) => {
|
||||||
|
return getMessageElement(messageId)?.getBoundingClientRect().height || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRenderableTailSignature = (message: UIChatMessage | undefined) => {
|
||||||
|
if (!message) return '';
|
||||||
|
|
||||||
|
const tailBlock: AssistantContentBlock | UIChatMessage =
|
||||||
|
message.children && message.children.length > 0 ? message.children.at(-1)! : message;
|
||||||
|
|
||||||
|
const contentLength = tailBlock.content?.length || 0;
|
||||||
|
const reasoningLength = tailBlock.reasoning?.content?.length || 0;
|
||||||
|
const toolCount = tailBlock.tools?.length || 0;
|
||||||
|
|
||||||
|
return `${contentLength}:${reasoningLength}:${toolCount}:${message.updatedAt || 0}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useConversationSpacer = (dataSource: string[]) => {
|
||||||
|
const displayMessages = useConversationStore(dataSelectors.displayMessages);
|
||||||
|
const isAIGenerating = useConversationStore(messageStateSelectors.isAIGenerating);
|
||||||
|
const getItemOffset = useConversationStore((s) => s.virtuaScrollMethods?.getItemOffset);
|
||||||
|
const getItemSize = useConversationStore((s) => s.virtuaScrollMethods?.getItemSize);
|
||||||
|
const getViewportSize = useConversationStore((s) => s.virtuaScrollMethods?.getViewportSize);
|
||||||
|
|
||||||
|
const [height, setHeight] = useState(0);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
const prevLengthRef = useRef(dataSource.length);
|
||||||
|
const removeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||||
|
const userMessageIndexRef = useRef<number | null>(null);
|
||||||
|
const assistantMessageIndexRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const getTrackedMessages = useCallback(() => {
|
||||||
|
const userIndex = userMessageIndexRef.current;
|
||||||
|
const assistantIndex = assistantMessageIndexRef.current;
|
||||||
|
|
||||||
|
return {
|
||||||
|
assistantId:
|
||||||
|
assistantIndex !== null && assistantIndex >= 0 ? dataSource[assistantIndex] || null : null,
|
||||||
|
assistantIndex,
|
||||||
|
userId: userIndex !== null && userIndex >= 0 ? dataSource[userIndex] || null : null,
|
||||||
|
userIndex,
|
||||||
|
};
|
||||||
|
}, [dataSource]);
|
||||||
|
|
||||||
|
const latestAssistantSignature = (() => {
|
||||||
|
const { assistantId } = getTrackedMessages();
|
||||||
|
if (!assistantId) return '';
|
||||||
|
|
||||||
|
const assistantMessage = displayMessages.find((message) => message.id === assistantId);
|
||||||
|
return getRenderableTailSignature(assistantMessage);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const clearRemoveTimer = useCallback(() => {
|
||||||
|
if (removeTimerRef.current) {
|
||||||
|
clearTimeout(removeTimerRef.current);
|
||||||
|
removeTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cleanupObserver = useCallback(() => {
|
||||||
|
resizeObserverRef.current?.disconnect();
|
||||||
|
resizeObserverRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scheduleUnmount = useCallback(() => {
|
||||||
|
clearRemoveTimer();
|
||||||
|
|
||||||
|
removeTimerRef.current = setTimeout(() => {
|
||||||
|
setMounted(false);
|
||||||
|
removeTimerRef.current = null;
|
||||||
|
}, CONVERSATION_SPACER_TRANSITION_MS);
|
||||||
|
}, [clearRemoveTimer]);
|
||||||
|
|
||||||
|
const updateSpacerHeight = useCallback(() => {
|
||||||
|
clearRemoveTimer();
|
||||||
|
const { assistantId, assistantIndex, userId, userIndex } = getTrackedMessages();
|
||||||
|
const viewportHeight = getViewportSize?.() || window.innerHeight;
|
||||||
|
|
||||||
|
let nextHeight: number;
|
||||||
|
|
||||||
|
if (userIndex !== null && assistantIndex !== null && getItemOffset && getItemSize) {
|
||||||
|
const userTop = getItemOffset(userIndex);
|
||||||
|
const assistantBottom = getItemOffset(assistantIndex) + getItemSize(assistantIndex);
|
||||||
|
|
||||||
|
nextHeight = Math.max(Math.round(viewportHeight - (assistantBottom - userTop)), 0);
|
||||||
|
} else {
|
||||||
|
const userHeight = getMessageHeight(userId);
|
||||||
|
if (!userHeight) return;
|
||||||
|
|
||||||
|
const assistantHeight = getMessageHeight(assistantId);
|
||||||
|
|
||||||
|
nextHeight = calculateConversationSpacerHeight(viewportHeight, userHeight, assistantHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextHeight === 0) {
|
||||||
|
setHeight(0);
|
||||||
|
scheduleUnmount();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMounted(true);
|
||||||
|
setHeight(nextHeight);
|
||||||
|
}, [
|
||||||
|
clearRemoveTimer,
|
||||||
|
getTrackedMessages,
|
||||||
|
getItemOffset,
|
||||||
|
getItemSize,
|
||||||
|
getViewportSize,
|
||||||
|
scheduleUnmount,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
cleanupObserver();
|
||||||
|
clearRemoveTimer();
|
||||||
|
};
|
||||||
|
}, [cleanupObserver, clearRemoveTimer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newMessageCount = dataSource.length - prevLengthRef.current;
|
||||||
|
prevLengthRef.current = dataSource.length;
|
||||||
|
|
||||||
|
const userMessage = displayMessages.at(-2);
|
||||||
|
const assistantMessage = displayMessages.at(-1);
|
||||||
|
|
||||||
|
if (newMessageCount !== 2 || userMessage?.role !== 'user' || !assistantMessage) return;
|
||||||
|
|
||||||
|
userMessageIndexRef.current = dataSource.length - 2;
|
||||||
|
assistantMessageIndexRef.current = dataSource.length - 1;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
updateSpacerHeight();
|
||||||
|
});
|
||||||
|
}, [dataSource.length, displayMessages, updateSpacerHeight]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { assistantId, userId } = getTrackedMessages();
|
||||||
|
|
||||||
|
cleanupObserver();
|
||||||
|
|
||||||
|
if (!assistantId || !userId || typeof ResizeObserver === 'undefined') return;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
updateSpacerHeight();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserverRef.current = observer;
|
||||||
|
|
||||||
|
const userEl = getMessageElement(userId);
|
||||||
|
const assistantEl = getMessageElement(assistantId);
|
||||||
|
|
||||||
|
if (userEl) observer.observe(userEl);
|
||||||
|
if (assistantEl) observer.observe(assistantEl);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
updateSpacerHeight();
|
||||||
|
});
|
||||||
|
|
||||||
|
return cleanupObserver;
|
||||||
|
}, [cleanupObserver, getTrackedMessages, latestAssistantSignature, updateSpacerHeight]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
updateSpacerHeight();
|
||||||
|
});
|
||||||
|
}, [isAIGenerating, latestAssistantSignature, mounted, updateSpacerHeight]);
|
||||||
|
|
||||||
|
const listData = useMemo(
|
||||||
|
() => (mounted ? [...dataSource, CONVERSATION_SPACER_ID] : dataSource),
|
||||||
|
[dataSource, mounted],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSpacerMessage: (id: string) => id === CONVERSATION_SPACER_ID,
|
||||||
|
listData,
|
||||||
|
spacerActive: mounted,
|
||||||
|
spacerHeight: height,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
import { renderHook } from '@testing-library/react';
|
import { act, renderHook } from '@testing-library/react';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { useScrollToUserMessage } from './useScrollToUserMessage';
|
import { useScrollToUserMessage } from './useScrollToUserMessage';
|
||||||
|
|
||||||
describe('useScrollToUserMessage', () => {
|
describe('useScrollToUserMessage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
describe('when user sends a new message', () => {
|
describe('when user sends a new message', () => {
|
||||||
it('should scroll to user message when 2 new messages are added (user + assistant pair)', () => {
|
it('should retry scrolling to user message when 2 new messages are added (user + assistant pair)', () => {
|
||||||
const scrollToIndex = vi.fn();
|
const scrollToIndex = vi.fn();
|
||||||
|
|
||||||
const { rerender } = renderHook(
|
const { rerender } = renderHook(
|
||||||
@@ -29,9 +37,14 @@ describe('useScrollToUserMessage', () => {
|
|||||||
isSecondLastMessageFromUser: true,
|
isSecondLastMessageFromUser: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(scrollToIndex).toHaveBeenCalledTimes(1);
|
act(() => {
|
||||||
// Should scroll to index 2 (dataSourceLength - 2 = 4 - 2 = 2, the user message)
|
vi.runAllTimers();
|
||||||
expect(scrollToIndex).toHaveBeenCalledWith(2, { align: 'start', smooth: true });
|
});
|
||||||
|
|
||||||
|
expect(scrollToIndex).toHaveBeenCalledTimes(3);
|
||||||
|
expect(scrollToIndex).toHaveBeenNthCalledWith(1, 2, { align: 'start', smooth: true });
|
||||||
|
expect(scrollToIndex).toHaveBeenNthCalledWith(2, 2, { align: 'start', smooth: true });
|
||||||
|
expect(scrollToIndex).toHaveBeenNthCalledWith(3, 2, { align: 'start', smooth: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should scroll to correct index when multiple user messages are sent', () => {
|
it('should scroll to correct index when multiple user messages are sent', () => {
|
||||||
@@ -58,8 +71,11 @@ describe('useScrollToUserMessage', () => {
|
|||||||
isSecondLastMessageFromUser: true,
|
isSecondLastMessageFromUser: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should scroll to index 4 (dataSourceLength - 2 = 6 - 2 = 4)
|
act(() => {
|
||||||
expect(scrollToIndex).toHaveBeenCalledWith(4, { align: 'start', smooth: true });
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scrollToIndex).toHaveBeenNthCalledWith(1, 4, { align: 'start', smooth: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
const PIN_RETRY_DELAYS = [0, 32, 96];
|
||||||
|
|
||||||
interface UseScrollToUserMessageOptions {
|
interface UseScrollToUserMessageOptions {
|
||||||
/**
|
/**
|
||||||
@@ -32,6 +34,18 @@ export function useScrollToUserMessage({
|
|||||||
scrollToIndex,
|
scrollToIndex,
|
||||||
}: UseScrollToUserMessageOptions): void {
|
}: UseScrollToUserMessageOptions): void {
|
||||||
const prevLengthRef = useRef(dataSourceLength);
|
const prevLengthRef = useRef(dataSourceLength);
|
||||||
|
const timerIdsRef = useRef<number[]>([]);
|
||||||
|
|
||||||
|
const clearPendingPins = useCallback(() => {
|
||||||
|
timerIdsRef.current.forEach((timerId) => {
|
||||||
|
window.clearTimeout(timerId);
|
||||||
|
});
|
||||||
|
timerIdsRef.current = [];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return clearPendingPins;
|
||||||
|
}, [clearPendingPins]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newMessageCount = dataSourceLength - prevLengthRef.current;
|
const newMessageCount = dataSourceLength - prevLengthRef.current;
|
||||||
@@ -39,8 +53,20 @@ export function useScrollToUserMessage({
|
|||||||
|
|
||||||
// Only scroll when user sends a new message (2 messages added: user + assistant pair)
|
// Only scroll when user sends a new message (2 messages added: user + assistant pair)
|
||||||
if (newMessageCount === 2 && isSecondLastMessageFromUser && scrollToIndex) {
|
if (newMessageCount === 2 && isSecondLastMessageFromUser && scrollToIndex) {
|
||||||
// Scroll to the second-to-last message (user's message) with the start aligned
|
const userMessageIndex = dataSourceLength - 2;
|
||||||
scrollToIndex(dataSourceLength - 2, { align: 'start', smooth: true });
|
|
||||||
|
clearPendingPins();
|
||||||
|
|
||||||
|
PIN_RETRY_DELAYS.forEach((delay) => {
|
||||||
|
const timerId = window.setTimeout(() => {
|
||||||
|
scrollToIndex(userMessageIndex, {
|
||||||
|
align: 'start',
|
||||||
|
smooth: true,
|
||||||
|
});
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
timerIdsRef.current.push(timerId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [dataSourceLength, isSecondLastMessageFromUser, scrollToIndex]);
|
}, [clearPendingPins, dataSourceLength, isSecondLastMessageFromUser, scrollToIndex]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { memo, useCallback } from 'react';
|
|||||||
|
|
||||||
import { MESSAGE_ACTION_BAR_PORTAL_ATTRIBUTES } from '@/const/messageActionPortal';
|
import { MESSAGE_ACTION_BAR_PORTAL_ATTRIBUTES } from '@/const/messageActionPortal';
|
||||||
import { ChatItem } from '@/features/Conversation/ChatItem';
|
import { ChatItem } from '@/features/Conversation/ChatItem';
|
||||||
import { useNewScreen } from '@/features/Conversation/Messages/components/useNewScreen';
|
|
||||||
import { useUserStore } from '@/store/user';
|
import { useUserStore } from '@/store/user';
|
||||||
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
|
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
|
||||||
|
|
||||||
@@ -34,108 +33,97 @@ interface AssistantMessageProps {
|
|||||||
isLatestItem?: boolean;
|
isLatestItem?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AssistantMessage = memo<AssistantMessageProps>(
|
const AssistantMessage = memo<AssistantMessageProps>(({ id, index, disableEditing }) => {
|
||||||
({ id, index, disableEditing, isLatestItem }) => {
|
// Get message and actionsConfig from ConversationStore
|
||||||
// Get message and actionsConfig from ConversationStore
|
const item = useConversationStore(dataSelectors.getDisplayMessageById(id), isEqual)!;
|
||||||
const item = useConversationStore(dataSelectors.getDisplayMessageById(id), isEqual)!;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
agentId,
|
agentId,
|
||||||
branch,
|
branch,
|
||||||
error,
|
error,
|
||||||
role,
|
role,
|
||||||
content,
|
content,
|
||||||
createdAt,
|
createdAt,
|
||||||
tools,
|
tools,
|
||||||
extra,
|
extra,
|
||||||
model,
|
model,
|
||||||
provider,
|
provider,
|
||||||
performance,
|
performance,
|
||||||
usage,
|
usage,
|
||||||
metadata,
|
metadata,
|
||||||
} = item;
|
} = item;
|
||||||
|
|
||||||
const avatar = useAgentMeta(agentId);
|
const avatar = useAgentMeta(agentId);
|
||||||
|
|
||||||
// Get editing and generating state from ConversationStore
|
// Get editing and generating state from ConversationStore
|
||||||
const editing = useConversationStore(messageStateSelectors.isMessageEditing(id));
|
const editing = useConversationStore(messageStateSelectors.isMessageEditing(id));
|
||||||
const generating = useConversationStore(messageStateSelectors.isMessageGenerating(id));
|
const generating = useConversationStore(messageStateSelectors.isMessageGenerating(id));
|
||||||
const creating = useConversationStore(messageStateSelectors.isMessageCreating(id));
|
|
||||||
const { minHeight } = useNewScreen({
|
|
||||||
creating: creating || generating,
|
|
||||||
isLatestItem,
|
|
||||||
messageId: id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const errorContent = useErrorContent(error);
|
const errorContent = useErrorContent(error);
|
||||||
|
|
||||||
// remove line breaks in artifact tag to make the ast transform easier
|
// remove line breaks in artifact tag to make the ast transform easier
|
||||||
const message = !editing ? normalizeThinkTags(processWithArtifact(content)) : content;
|
const message = !editing ? normalizeThinkTags(processWithArtifact(content)) : content;
|
||||||
|
|
||||||
const onDoubleClick = useDoubleClickEdit({ disableEditing, error, id, role });
|
const onDoubleClick = useDoubleClickEdit({ disableEditing, error, id, role });
|
||||||
const setMessageItemActionElementPortialContext =
|
const setMessageItemActionElementPortialContext = useSetMessageItemActionElementPortialContext();
|
||||||
useSetMessageItemActionElementPortialContext();
|
const setMessageItemActionTypeContext = useSetMessageItemActionTypeContext();
|
||||||
const setMessageItemActionTypeContext = useSetMessageItemActionTypeContext();
|
|
||||||
|
|
||||||
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
|
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
|
||||||
|
|
||||||
const onMouseEnter: MouseEventHandler<HTMLDivElement> = useCallback(
|
const onMouseEnter: MouseEventHandler<HTMLDivElement> = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
setMessageItemActionElementPortialContext(e.currentTarget);
|
setMessageItemActionElementPortialContext(e.currentTarget);
|
||||||
setMessageItemActionTypeContext({ id, index, type: 'assistant' });
|
setMessageItemActionTypeContext({ id, index, type: 'assistant' });
|
||||||
},
|
},
|
||||||
[id, index, setMessageItemActionElementPortialContext, setMessageItemActionTypeContext],
|
[id, index, setMessageItemActionElementPortialContext, setMessageItemActionTypeContext],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChatItem
|
<ChatItem
|
||||||
showTitle
|
showTitle
|
||||||
aboveMessage={null}
|
aboveMessage={null}
|
||||||
avatar={avatar}
|
avatar={avatar}
|
||||||
customErrorRender={(error) => <ErrorMessageExtra data={item} error={error} />}
|
customErrorRender={(error) => <ErrorMessageExtra data={item} error={error} />}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
id={id}
|
id={id}
|
||||||
loading={generating}
|
loading={generating}
|
||||||
message={message}
|
message={message}
|
||||||
newScreenMinHeight={minHeight}
|
placement={'left'}
|
||||||
placement={'left'}
|
time={createdAt}
|
||||||
time={createdAt}
|
actions={
|
||||||
actions={
|
<>
|
||||||
<>
|
{isDevMode && branch && (
|
||||||
{isDevMode && branch && (
|
<MessageBranch
|
||||||
<MessageBranch
|
activeBranchIndex={branch.activeBranchIndex}
|
||||||
activeBranchIndex={branch.activeBranchIndex}
|
count={branch.count}
|
||||||
count={branch.count}
|
messageId={id}
|
||||||
messageId={id}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
{actionBarHolder}
|
||||||
{actionBarHolder}
|
</>
|
||||||
</>
|
}
|
||||||
}
|
error={
|
||||||
error={
|
errorContent && error && (message === LOADING_FLAT || !message) ? errorContent : undefined
|
||||||
errorContent && error && (message === LOADING_FLAT || !message) ? errorContent : undefined
|
}
|
||||||
}
|
messageExtra={
|
||||||
messageExtra={
|
<AssistantMessageExtra
|
||||||
<AssistantMessageExtra
|
content={content}
|
||||||
content={content}
|
extra={extra}
|
||||||
extra={extra}
|
id={id}
|
||||||
id={id}
|
model={model!}
|
||||||
model={model!}
|
performance={performance! || metadata}
|
||||||
performance={performance! || metadata}
|
provider={provider!}
|
||||||
provider={provider!}
|
tools={tools}
|
||||||
tools={tools}
|
usage={usage! || metadata}
|
||||||
usage={usage! || metadata}
|
/>
|
||||||
/>
|
}
|
||||||
}
|
onDoubleClick={onDoubleClick}
|
||||||
onDoubleClick={onDoubleClick}
|
onMouseEnter={onMouseEnter}
|
||||||
onMouseEnter={onMouseEnter}
|
>
|
||||||
>
|
<MessageContent {...item} />
|
||||||
<MessageContent {...item} />
|
</ChatItem>
|
||||||
</ChatItem>
|
);
|
||||||
);
|
}, isEqual);
|
||||||
},
|
|
||||||
isEqual,
|
|
||||||
);
|
|
||||||
|
|
||||||
AssistantMessage.displayName = 'AssistantMessage';
|
AssistantMessage.displayName = 'AssistantMessage';
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { memo, Suspense, useCallback, useMemo } from 'react';
|
|||||||
|
|
||||||
import { MESSAGE_ACTION_BAR_PORTAL_ATTRIBUTES } from '@/const/messageActionPortal';
|
import { MESSAGE_ACTION_BAR_PORTAL_ATTRIBUTES } from '@/const/messageActionPortal';
|
||||||
import { ChatItem } from '@/features/Conversation/ChatItem';
|
import { ChatItem } from '@/features/Conversation/ChatItem';
|
||||||
import { useNewScreen } from '@/features/Conversation/Messages/components/useNewScreen';
|
|
||||||
import { useOpenChatSettings } from '@/hooks/useInterceptingRoutes';
|
import { useOpenChatSettings } from '@/hooks/useInterceptingRoutes';
|
||||||
import dynamic from '@/libs/next/dynamic';
|
import dynamic from '@/libs/next/dynamic';
|
||||||
import { useAgentStore } from '@/store/agent';
|
import { useAgentStore } from '@/store/agent';
|
||||||
@@ -45,7 +44,7 @@ interface GroupMessageProps {
|
|||||||
isLatestItem?: boolean;
|
isLatestItem?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLatestItem }) => {
|
const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing }) => {
|
||||||
// Get message and actionsConfig from ConversationStore
|
// Get message and actionsConfig from ConversationStore
|
||||||
const item = useConversationStore(dataSelectors.getDisplayMessageById(id), isEqual)!;
|
const item = useConversationStore(dataSelectors.getDisplayMessageById(id), isEqual)!;
|
||||||
|
|
||||||
@@ -72,13 +71,6 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
|
|||||||
|
|
||||||
// Get editing state from ConversationStore
|
// Get editing state from ConversationStore
|
||||||
const editing = useConversationStore(messageStateSelectors.isMessageEditing(contentId || ''));
|
const editing = useConversationStore(messageStateSelectors.isMessageEditing(contentId || ''));
|
||||||
const creating = useConversationStore(messageStateSelectors.isMessageCreating(id));
|
|
||||||
const generating = useConversationStore(messageStateSelectors.isMessageGenerating(id));
|
|
||||||
const { minHeight } = useNewScreen({
|
|
||||||
creating: creating || generating,
|
|
||||||
isLatestItem,
|
|
||||||
messageId: id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
|
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
|
||||||
const addReaction = useConversationStore((s) => s.addReaction);
|
const addReaction = useConversationStore((s) => s.addReaction);
|
||||||
@@ -136,7 +128,6 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
|
|||||||
<ChatItem
|
<ChatItem
|
||||||
showTitle
|
showTitle
|
||||||
avatar={avatar}
|
avatar={avatar}
|
||||||
newScreenMinHeight={minHeight}
|
|
||||||
placement={'left'}
|
placement={'left'}
|
||||||
time={createdAt}
|
time={createdAt}
|
||||||
actions={
|
actions={
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { MESSAGE_ACTION_BAR_PORTAL_ATTRIBUTES } from '@/const/messageActionPortal';
|
import { MESSAGE_ACTION_BAR_PORTAL_ATTRIBUTES } from '@/const/messageActionPortal';
|
||||||
import AgentGroupAvatar from '@/features/AgentGroupAvatar';
|
import AgentGroupAvatar from '@/features/AgentGroupAvatar';
|
||||||
import { ChatItem } from '@/features/Conversation/ChatItem';
|
import { ChatItem } from '@/features/Conversation/ChatItem';
|
||||||
import { useNewScreen } from '@/features/Conversation/Messages/components/useNewScreen';
|
|
||||||
import { useAgentGroupStore } from '@/store/agentGroup';
|
import { useAgentGroupStore } from '@/store/agentGroup';
|
||||||
import { agentGroupSelectors } from '@/store/agentGroup/selectors';
|
import { agentGroupSelectors } from '@/store/agentGroup/selectors';
|
||||||
import { useUserStore } from '@/store/user';
|
import { useUserStore } from '@/store/user';
|
||||||
@@ -18,7 +17,7 @@ import { userGeneralSettingsSelectors, userProfileSelectors } from '@/store/user
|
|||||||
|
|
||||||
import { ReactionDisplay } from '../../components/Reaction';
|
import { ReactionDisplay } from '../../components/Reaction';
|
||||||
import { useAgentMeta } from '../../hooks';
|
import { useAgentMeta } from '../../hooks';
|
||||||
import { dataSelectors, messageStateSelectors, useConversationStore } from '../../store';
|
import { dataSelectors, useConversationStore } from '../../store';
|
||||||
import Usage from '../components/Extras/Usage';
|
import Usage from '../components/Extras/Usage';
|
||||||
import MessageBranch from '../components/MessageBranch';
|
import MessageBranch from '../components/MessageBranch';
|
||||||
import {
|
import {
|
||||||
@@ -40,7 +39,7 @@ interface GroupMessageProps {
|
|||||||
isLatestItem?: boolean;
|
isLatestItem?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLatestItem }) => {
|
const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing }) => {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
|
|
||||||
// Get message and actionsConfig from ConversationStore
|
// Get message and actionsConfig from ConversationStore
|
||||||
@@ -60,14 +59,6 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
|
|||||||
const groupMeta = useAgentGroupStore(agentGroupSelectors.currentGroupMeta);
|
const groupMeta = useAgentGroupStore(agentGroupSelectors.currentGroupMeta);
|
||||||
|
|
||||||
// Get editing state from ConversationStore
|
// Get editing state from ConversationStore
|
||||||
const creating = useConversationStore(messageStateSelectors.isMessageCreating(id));
|
|
||||||
const generating = useConversationStore(messageStateSelectors.isMessageGenerating(id));
|
|
||||||
const { minHeight } = useNewScreen({
|
|
||||||
creating: creating || generating,
|
|
||||||
isLatestItem,
|
|
||||||
messageId: id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
|
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
|
||||||
const addReaction = useConversationStore((s) => s.addReaction);
|
const addReaction = useConversationStore((s) => s.addReaction);
|
||||||
const removeReaction = useConversationStore((s) => s.removeReaction);
|
const removeReaction = useConversationStore((s) => s.removeReaction);
|
||||||
@@ -116,7 +107,6 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
|
|||||||
<ChatItem
|
<ChatItem
|
||||||
showTitle
|
showTitle
|
||||||
avatar={{ ...avatar, title: groupMeta.title }}
|
avatar={{ ...avatar, title: groupMeta.title }}
|
||||||
newScreenMinHeight={minHeight}
|
|
||||||
placement={'left'}
|
placement={'left'}
|
||||||
time={createdAt}
|
time={createdAt}
|
||||||
titleAddon={<Tag>{t('supervisor.label')}</Tag>}
|
titleAddon={<Tag>{t('supervisor.label')}</Tag>}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { memo, useCallback } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { ChatItem } from '@/features/Conversation/ChatItem';
|
import { ChatItem } from '@/features/Conversation/ChatItem';
|
||||||
import { useNewScreen } from '@/features/Conversation/Messages/components/useNewScreen';
|
|
||||||
import TaskAvatar from '@/features/Conversation/Messages/Tasks/shared/TaskAvatar';
|
import TaskAvatar from '@/features/Conversation/Messages/Tasks/shared/TaskAvatar';
|
||||||
import { useOpenChatSettings } from '@/hooks/useInterceptingRoutes';
|
import { useOpenChatSettings } from '@/hooks/useInterceptingRoutes';
|
||||||
import { useAgentStore } from '@/store/agent';
|
import { useAgentStore } from '@/store/agent';
|
||||||
@@ -29,7 +28,7 @@ interface TaskMessageProps {
|
|||||||
isLatestItem?: boolean;
|
isLatestItem?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TaskMessage = memo<TaskMessageProps>(({ id, index, disableEditing, isLatestItem }) => {
|
const TaskMessage = memo<TaskMessageProps>(({ id, index, disableEditing }) => {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
|
|
||||||
// Get message and actionsConfig from ConversationStore
|
// Get message and actionsConfig from ConversationStore
|
||||||
@@ -43,12 +42,6 @@ const TaskMessage = memo<TaskMessageProps>(({ id, index, disableEditing, isLates
|
|||||||
// Get editing and generating state from ConversationStore
|
// Get editing and generating state from ConversationStore
|
||||||
const editing = useConversationStore(messageStateSelectors.isMessageEditing(id));
|
const editing = useConversationStore(messageStateSelectors.isMessageEditing(id));
|
||||||
const generating = useConversationStore(messageStateSelectors.isMessageGenerating(id));
|
const generating = useConversationStore(messageStateSelectors.isMessageGenerating(id));
|
||||||
const creating = useConversationStore(messageStateSelectors.isMessageCreating(id));
|
|
||||||
const { minHeight } = useNewScreen({
|
|
||||||
creating: generating || creating,
|
|
||||||
isLatestItem,
|
|
||||||
messageId: id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const errorContent = useErrorContent(error);
|
const errorContent = useErrorContent(error);
|
||||||
|
|
||||||
@@ -83,7 +76,6 @@ const TaskMessage = memo<TaskMessageProps>(({ id, index, disableEditing, isLates
|
|||||||
id={id}
|
id={id}
|
||||||
loading={generating}
|
loading={generating}
|
||||||
message={message}
|
message={message}
|
||||||
newScreenMinHeight={minHeight}
|
|
||||||
placement={'left'}
|
placement={'left'}
|
||||||
time={createdAt}
|
time={createdAt}
|
||||||
titleAddon={<Tag>{t('task.subtask')}</Tag>}
|
titleAddon={<Tag>{t('task.subtask')}</Tag>}
|
||||||
|
|||||||
@@ -1,331 +0,0 @@
|
|||||||
import { renderHook } from '@testing-library/react';
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { useNewScreen } from './useNewScreen';
|
|
||||||
|
|
||||||
// Mock useConversationStore
|
|
||||||
const mockGetViewportSize = vi.fn();
|
|
||||||
vi.mock('../../store', () => ({
|
|
||||||
useConversationStore: vi.fn((selector) =>
|
|
||||||
selector({
|
|
||||||
virtuaScrollMethods: {
|
|
||||||
getViewportSize: mockGetViewportSize,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('useNewScreen', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockGetViewportSize.mockReturnValue(800);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when not the latest item', () => {
|
|
||||||
it('should return undefined minHeight', () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useNewScreen({
|
|
||||||
creating: true,
|
|
||||||
isLatestItem: false,
|
|
||||||
messageId: 'msg-1',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.minHeight).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clear minHeight when becoming not the latest item', () => {
|
|
||||||
const { result, rerender } = renderHook(
|
|
||||||
({ isLatestItem }) =>
|
|
||||||
useNewScreen({
|
|
||||||
creating: true,
|
|
||||||
isLatestItem,
|
|
||||||
messageId: 'msg-1',
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
initialProps: { isLatestItem: true },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Initially, it will use fallback since no DOM elements exist
|
|
||||||
expect(result.current.minHeight).toBeDefined();
|
|
||||||
|
|
||||||
// When it's no longer the latest item
|
|
||||||
rerender({ isLatestItem: false });
|
|
||||||
|
|
||||||
expect(result.current.minHeight).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when latest item but not creating', () => {
|
|
||||||
it('should not update minHeight (preserve existing value)', () => {
|
|
||||||
const { result, rerender } = renderHook(
|
|
||||||
({ creating }) =>
|
|
||||||
useNewScreen({
|
|
||||||
creating,
|
|
||||||
isLatestItem: true,
|
|
||||||
messageId: 'msg-1',
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
initialProps: { creating: true },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Initially sets minHeight (fallback)
|
|
||||||
const initialMinHeight = result.current.minHeight;
|
|
||||||
expect(initialMinHeight).toBeDefined();
|
|
||||||
|
|
||||||
// When creating ends, should preserve the minHeight
|
|
||||||
rerender({ creating: false });
|
|
||||||
|
|
||||||
expect(result.current.minHeight).toBe(initialMinHeight);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when latest item and creating', () => {
|
|
||||||
it('should calculate minHeight based on viewport and previous message height', () => {
|
|
||||||
// Setup DOM mocks
|
|
||||||
const mockPrevMessageEl = {
|
|
||||||
getBoundingClientRect: () => ({ height: 150 }),
|
|
||||||
};
|
|
||||||
const mockPrevWrapper = {
|
|
||||||
querySelector: vi.fn().mockReturnValue(mockPrevMessageEl),
|
|
||||||
getBoundingClientRect: () => ({ height: 150 }),
|
|
||||||
};
|
|
||||||
const mockCurrentWrapper = {
|
|
||||||
dataset: { index: '1' },
|
|
||||||
};
|
|
||||||
const mockMessageEl = {
|
|
||||||
closest: vi.fn().mockReturnValue(mockCurrentWrapper),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.spyOn(document, 'querySelector').mockImplementation((selector: string) => {
|
|
||||||
if (selector === '[data-message-id="msg-1"]') {
|
|
||||||
return mockMessageEl as unknown as Element;
|
|
||||||
}
|
|
||||||
if (selector === '[data-index="0"]') {
|
|
||||||
return mockPrevWrapper as unknown as Element;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
mockGetViewportSize.mockReturnValue(800);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useNewScreen({
|
|
||||||
creating: true,
|
|
||||||
isLatestItem: true,
|
|
||||||
messageId: 'msg-1',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// minHeight = viewportHeight - prevHeight - EXTRA_PADDING = 800 - 150 - 0 = 650
|
|
||||||
expect(result.current.minHeight).toBe('650px');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use fallback height when previous message element not found', () => {
|
|
||||||
// Setup DOM mocks - no previous element
|
|
||||||
const mockCurrentWrapper = {
|
|
||||||
dataset: { index: '0' },
|
|
||||||
querySelector: vi.fn().mockReturnValue(null),
|
|
||||||
};
|
|
||||||
const mockMessageEl = {
|
|
||||||
closest: vi.fn().mockReturnValue(mockCurrentWrapper),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.spyOn(document, 'querySelector').mockImplementation((selector: string) => {
|
|
||||||
if (selector === '[data-message-id="msg-1"]') {
|
|
||||||
return mockMessageEl as unknown as Element;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
mockGetViewportSize.mockReturnValue(800);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useNewScreen({
|
|
||||||
creating: true,
|
|
||||||
isLatestItem: true,
|
|
||||||
messageId: 'msg-1',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// fallback: viewportHeight - DEFAULT_USER_MESSAGE_HEIGHT - EXTRA_PADDING = 800 - 200 - 0 = 600
|
|
||||||
expect(result.current.minHeight).toBe('600px');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return undefined when calculated height is less than or equal to 0', () => {
|
|
||||||
// Setup DOM mocks with very large previous message
|
|
||||||
const mockPrevMessageEl = {
|
|
||||||
getBoundingClientRect: () => ({ height: 900 }), // Larger than viewport
|
|
||||||
};
|
|
||||||
const mockPrevWrapper = {
|
|
||||||
querySelector: vi.fn().mockReturnValue(mockPrevMessageEl),
|
|
||||||
getBoundingClientRect: () => ({ height: 900 }),
|
|
||||||
};
|
|
||||||
const mockCurrentWrapper = {
|
|
||||||
dataset: { index: '1' },
|
|
||||||
};
|
|
||||||
const mockMessageEl = {
|
|
||||||
closest: vi.fn().mockReturnValue(mockCurrentWrapper),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.spyOn(document, 'querySelector').mockImplementation((selector: string) => {
|
|
||||||
if (selector === '[data-message-id="msg-1"]') {
|
|
||||||
return mockMessageEl as unknown as Element;
|
|
||||||
}
|
|
||||||
if (selector === '[data-index="0"]') {
|
|
||||||
return mockPrevWrapper as unknown as Element;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
mockGetViewportSize.mockReturnValue(800);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useNewScreen({
|
|
||||||
creating: true,
|
|
||||||
isLatestItem: true,
|
|
||||||
messageId: 'msg-1',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// minHeight = 800 - 900 - 0 = -100, should be undefined
|
|
||||||
expect(result.current.minHeight).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use window.innerHeight when virtuaScrollMethods is not available', () => {
|
|
||||||
// Reset mock to return undefined for virtuaScrollMethods
|
|
||||||
vi.mocked(mockGetViewportSize).mockReturnValue(undefined as unknown as number);
|
|
||||||
|
|
||||||
// Mock window.innerHeight
|
|
||||||
Object.defineProperty(window, 'innerHeight', { value: 768, writable: true });
|
|
||||||
|
|
||||||
// Setup DOM mocks - no previous element (fallback case)
|
|
||||||
const mockCurrentWrapper = {
|
|
||||||
dataset: { index: '0' },
|
|
||||||
querySelector: vi.fn().mockReturnValue(null),
|
|
||||||
};
|
|
||||||
const mockMessageEl = {
|
|
||||||
closest: vi.fn().mockReturnValue(mockCurrentWrapper),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.spyOn(document, 'querySelector').mockImplementation((selector: string) => {
|
|
||||||
if (selector === '[data-message-id="msg-1"]') {
|
|
||||||
return mockMessageEl as unknown as Element;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useNewScreen({
|
|
||||||
creating: true,
|
|
||||||
isLatestItem: true,
|
|
||||||
messageId: 'msg-1',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// fallback: window.innerHeight - DEFAULT_USER_MESSAGE_HEIGHT = 768 - 200 = 568
|
|
||||||
expect(result.current.minHeight).toBe('568px');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('edge cases', () => {
|
|
||||||
it('should handle message element not found', () => {
|
|
||||||
vi.spyOn(document, 'querySelector').mockReturnValue(null);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useNewScreen({
|
|
||||||
creating: true,
|
|
||||||
isLatestItem: true,
|
|
||||||
messageId: 'non-existent',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should use fallback
|
|
||||||
expect(result.current.minHeight).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle negative prevIndex', () => {
|
|
||||||
const mockCurrentWrapper = {
|
|
||||||
dataset: { index: '0' }, // First item, prevIndex would be -1
|
|
||||||
querySelector: vi.fn().mockReturnValue(null),
|
|
||||||
};
|
|
||||||
const mockMessageEl = {
|
|
||||||
closest: vi.fn().mockReturnValue(mockCurrentWrapper),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.spyOn(document, 'querySelector').mockImplementation((selector: string) => {
|
|
||||||
if (selector === '[data-message-id="msg-1"]') {
|
|
||||||
return mockMessageEl as unknown as Element;
|
|
||||||
}
|
|
||||||
// Should not query for [data-index="-1"]
|
|
||||||
if (selector === '[data-index="-1"]') {
|
|
||||||
throw new Error('Should not query for negative index');
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
mockGetViewportSize.mockReturnValue(800);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useNewScreen({
|
|
||||||
creating: true,
|
|
||||||
isLatestItem: true,
|
|
||||||
messageId: 'msg-1',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should use fallback without throwing
|
|
||||||
expect(result.current.minHeight).toBe('600px');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should recalculate when messageId changes', () => {
|
|
||||||
const mockPrevMessageEl = {
|
|
||||||
getBoundingClientRect: () => ({ height: 150 }),
|
|
||||||
};
|
|
||||||
const mockPrevWrapper = {
|
|
||||||
querySelector: vi.fn().mockReturnValue(mockPrevMessageEl),
|
|
||||||
getBoundingClientRect: () => ({ height: 150 }),
|
|
||||||
};
|
|
||||||
const mockCurrentWrapper = {
|
|
||||||
dataset: { index: '1' },
|
|
||||||
};
|
|
||||||
const mockMessageEl = {
|
|
||||||
closest: vi.fn().mockReturnValue(mockCurrentWrapper),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.spyOn(document, 'querySelector').mockImplementation((selector: string) => {
|
|
||||||
if (selector.includes('data-message-id')) {
|
|
||||||
return mockMessageEl as unknown as Element;
|
|
||||||
}
|
|
||||||
if (selector === '[data-index="0"]') {
|
|
||||||
return mockPrevWrapper as unknown as Element;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
mockGetViewportSize.mockReturnValue(800);
|
|
||||||
|
|
||||||
const { result, rerender } = renderHook(
|
|
||||||
({ messageId }) =>
|
|
||||||
useNewScreen({
|
|
||||||
creating: true,
|
|
||||||
isLatestItem: true,
|
|
||||||
messageId,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
initialProps: { messageId: 'msg-1' },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.minHeight).toBe('650px');
|
|
||||||
|
|
||||||
// Change messageId - should recalculate
|
|
||||||
rerender({ messageId: 'msg-2' });
|
|
||||||
|
|
||||||
// Still the same value since mocks return same values
|
|
||||||
expect(result.current.minHeight).toBe('650px');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import debug from 'debug';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { useConversationStore } from '../../store';
|
|
||||||
|
|
||||||
const log = debug('lobe-render:Conversation:newScreen');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extra padding if needed
|
|
||||||
*/
|
|
||||||
const EXTRA_PADDING = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default user message height (fallback)
|
|
||||||
*/
|
|
||||||
const DEFAULT_USER_MESSAGE_HEIGHT = 200;
|
|
||||||
|
|
||||||
export const useNewScreen = ({
|
|
||||||
isLatestItem,
|
|
||||||
creating,
|
|
||||||
messageId,
|
|
||||||
}: {
|
|
||||||
creating?: boolean;
|
|
||||||
isLatestItem?: boolean;
|
|
||||||
messageId: string;
|
|
||||||
}) => {
|
|
||||||
const [minHeight, setMinHeight] = useState<string | undefined>(undefined);
|
|
||||||
const virtuaScrollMethods = useConversationStore((s) => s.virtuaScrollMethods);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Clear minHeight when no longer the latest item
|
|
||||||
if (!isLatestItem) {
|
|
||||||
setMinHeight(undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only calculate and set minHeight when creating, preserve after creating ends
|
|
||||||
if (!creating) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find current message element by data-message-id
|
|
||||||
const messageEl = document.querySelector(`[data-message-id="${messageId}"]`);
|
|
||||||
// Find VList item container (has data-index attribute)
|
|
||||||
const currentWrapper = messageEl?.closest('[data-index]') as HTMLElement | null;
|
|
||||||
// Get current index
|
|
||||||
const currentIndex = currentWrapper?.dataset.index;
|
|
||||||
|
|
||||||
// Find previous VList item by data-index (avoid sibling not existing due to virtualization)
|
|
||||||
const prevIndex = currentIndex ? Number(currentIndex) - 1 : -1;
|
|
||||||
const prevWrapper =
|
|
||||||
prevIndex >= 0 ? document.querySelector(`[data-index="${prevIndex}"]`) : null;
|
|
||||||
// Get previous message's .message-wrapper
|
|
||||||
const prevMessageEl = prevWrapper?.querySelector('.message-wrapper');
|
|
||||||
|
|
||||||
// Get real viewport height from VList
|
|
||||||
const viewportHeight = virtuaScrollMethods?.getViewportSize?.() || window.innerHeight;
|
|
||||||
|
|
||||||
if (prevMessageEl) {
|
|
||||||
const prevHeight = prevMessageEl.getBoundingClientRect().height;
|
|
||||||
|
|
||||||
// Goal: userMessage at top, so assistantMinHeight = viewportHeight - userMessageHeight
|
|
||||||
const calculatedHeight = viewportHeight - prevHeight - EXTRA_PADDING;
|
|
||||||
|
|
||||||
log(
|
|
||||||
'calculate minHeight: messageId=%s, index=%s, viewportHeight=%d, prevHeight=%d, result=%d',
|
|
||||||
messageId,
|
|
||||||
currentIndex,
|
|
||||||
viewportHeight,
|
|
||||||
prevHeight,
|
|
||||||
calculatedHeight,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Don't set minHeight if calculated height <= 0
|
|
||||||
setMinHeight(calculatedHeight > 0 ? `${calculatedHeight}px` : undefined);
|
|
||||||
} else {
|
|
||||||
// Fallback: use default value
|
|
||||||
const fallbackHeight = viewportHeight - DEFAULT_USER_MESSAGE_HEIGHT - EXTRA_PADDING;
|
|
||||||
log(
|
|
||||||
'fallback minHeight: messageId=%s, viewportHeight=%d, fallbackHeight=%d',
|
|
||||||
messageId,
|
|
||||||
viewportHeight,
|
|
||||||
fallbackHeight,
|
|
||||||
);
|
|
||||||
// Don't set minHeight if calculated height <= 0
|
|
||||||
setMinHeight(fallbackHeight > 0 ? `${fallbackHeight}px` : undefined);
|
|
||||||
}
|
|
||||||
}, [isLatestItem, creating, messageId, virtuaScrollMethods]);
|
|
||||||
|
|
||||||
return { minHeight };
|
|
||||||
};
|
|
||||||
@@ -2,9 +2,12 @@
|
|||||||
* Scroll methods exposed by VList, stored as callable functions
|
* Scroll methods exposed by VList, stored as callable functions
|
||||||
*/
|
*/
|
||||||
export interface VirtuaScrollMethods {
|
export interface VirtuaScrollMethods {
|
||||||
|
getItemOffset: (index: number) => number;
|
||||||
|
getItemSize: (index: number) => number;
|
||||||
getScrollOffset: () => number;
|
getScrollOffset: () => number;
|
||||||
getScrollSize: () => number;
|
getScrollSize: () => number;
|
||||||
getViewportSize: () => number;
|
getViewportSize: () => number;
|
||||||
|
scrollTo: (offset: number) => void;
|
||||||
scrollToIndex: (
|
scrollToIndex: (
|
||||||
index: number,
|
index: number,
|
||||||
options?: { align?: 'start' | 'center' | 'end'; smooth?: boolean },
|
options?: { align?: 'start' | 'center' | 'end'; smooth?: boolean },
|
||||||
|
|||||||
Reference in New Issue
Block a user