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 {
|
||||
server: {
|
||||
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();
|
||||
},
|
||||
handleSendButton: () => {
|
||||
if (!get().editor) return;
|
||||
|
||||
const editor = get().editor;
|
||||
if (!editor) return;
|
||||
|
||||
get().onSend?.({
|
||||
clearContent: () => editor?.cleanDocument(),
|
||||
@@ -42,6 +41,11 @@ export const store: CreateStore = (publicState) => (set, get) => ({
|
||||
getEditorData: get().getJSONState,
|
||||
getMarkdownContent: get().getMarkdownContent,
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
editor.focus();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
handleStop: () => {
|
||||
|
||||
@@ -39,7 +39,6 @@ const ChatItem = memo<ChatItemProps>(
|
||||
disabled = false,
|
||||
id,
|
||||
style,
|
||||
newScreenMinHeight,
|
||||
...rest
|
||||
}) => {
|
||||
const isUser = placement === 'right';
|
||||
@@ -68,7 +67,6 @@ const ChatItem = memo<ChatItemProps>(
|
||||
gap={8}
|
||||
paddingBlock={8}
|
||||
style={{
|
||||
minHeight: newScreenMinHeight,
|
||||
paddingInlineStart: isUser ? 36 : 0,
|
||||
...style,
|
||||
}}
|
||||
|
||||
@@ -36,9 +36,8 @@ export interface ChatItemProps extends Omit<FlexboxProps, 'children' | 'onChange
|
||||
message?: ReactNode;
|
||||
messageExtra?: ReactNode;
|
||||
/**
|
||||
* Dynamic min-height for new screen effect, e.g. "calc(100dvh - 350px)"
|
||||
* Avatar click handler
|
||||
*/
|
||||
newScreenMinHeight?: string;
|
||||
onAvatarClick?: () => void;
|
||||
onDoubleClick?: DivProps['onDoubleClick'];
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
useConversationStore,
|
||||
virtuaListSelectors,
|
||||
} from '../../../store';
|
||||
import { useAutoScrollEnabled } from './useAutoScrollEnabled';
|
||||
|
||||
/**
|
||||
* AutoScroll component - handles auto-scrolling logic during AI generation.
|
||||
@@ -23,9 +22,8 @@ const AutoScroll = memo(() => {
|
||||
const isGenerating = useConversationStore(messageStateSelectors.isAIGenerating);
|
||||
const scrollToBottom = useConversationStore((s) => s.scrollToBottom);
|
||||
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
|
||||
const lastMessage = dbMessages.at(-1);
|
||||
|
||||
@@ -8,10 +8,15 @@ import { VList } from 'virtua';
|
||||
|
||||
import WideScreenContainer from '../../../WideScreenContainer';
|
||||
import { dataSelectors, useConversationStore, virtuaListSelectors } from '../../store';
|
||||
import {
|
||||
CONVERSATION_SPACER_TRANSITION_MS,
|
||||
useConversationSpacer,
|
||||
} from '../hooks/useConversationSpacer';
|
||||
import { useScrollToUserMessage } from '../hooks/useScrollToUserMessage';
|
||||
import AutoScroll from './AutoScroll';
|
||||
import { AT_BOTTOM_THRESHOLD } from './AutoScroll/const';
|
||||
import DebugInspector, { OPEN_DEV_INSPECTOR } from './AutoScroll/DebugInspector';
|
||||
import { useAutoScrollEnabled } from './AutoScroll/useAutoScrollEnabled';
|
||||
import BackBottom from './BackBottom';
|
||||
|
||||
interface VirtualizedListProps {
|
||||
@@ -27,6 +32,9 @@ interface VirtualizedListProps {
|
||||
const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent }) => {
|
||||
const virtuaRef = useRef<VListHandle>(null);
|
||||
const scrollEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const { isSpacerMessage, listData, spacerHeight, spacerActive } =
|
||||
useConversationSpacer(dataSource);
|
||||
const isAutoScrollEnabled = useAutoScrollEnabled();
|
||||
|
||||
// Store actions
|
||||
const registerVirtuaScrollMethods = useConversationStore((s) => s.registerVirtuaScrollMethods);
|
||||
@@ -85,9 +93,12 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
|
||||
const ref = virtuaRef.current;
|
||||
if (ref) {
|
||||
registerVirtuaScrollMethods({
|
||||
getItemOffset: (index) => ref.getItemOffset(index),
|
||||
getItemSize: (index) => ref.getItemSize(index),
|
||||
getScrollOffset: () => ref.scrollOffset,
|
||||
getScrollSize: () => ref.scrollSize,
|
||||
getViewportSize: () => ref.viewportSize,
|
||||
scrollTo: (offset) => ref.scrollTo(offset),
|
||||
scrollToIndex: (index, options) => ref.scrollToIndex(index, options),
|
||||
});
|
||||
|
||||
@@ -143,13 +154,29 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
|
||||
{OPEN_DEV_INSPECTOR && <DebugInspector />}
|
||||
<VList
|
||||
bufferSize={typeof window !== 'undefined' ? window.innerHeight : 0}
|
||||
data={dataSource}
|
||||
data={listData}
|
||||
ref={virtuaRef}
|
||||
style={{ height: '100%', overflowAnchor: 'none', paddingBottom: 24 }}
|
||||
onScroll={handleScroll}
|
||||
onScrollEnd={handleScrollEnd}
|
||||
>
|
||||
{(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 isLastItem = index === dataSource.length - 1;
|
||||
const content = itemContent(index, messageId);
|
||||
@@ -160,7 +187,7 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
|
||||
<div key={messageId} style={{ position: 'relative', width: '100%' }}>
|
||||
{content}
|
||||
{/* AutoScroll is placed inside the last Item so it only triggers when the last Item is visible */}
|
||||
{isLastItem && <AutoScroll />}
|
||||
{isLastItem && isAutoScrollEnabled && !spacerActive && <AutoScroll />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -168,8 +195,7 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
|
||||
return (
|
||||
<WideScreenContainer key={messageId} style={{ position: 'relative' }}>
|
||||
{content}
|
||||
{/* AutoScroll is placed inside the last Item so it only triggers when the last Item is visible */}
|
||||
{isLastItem && <AutoScroll />}
|
||||
{isLastItem && isAutoScrollEnabled && !spacerActive && <AutoScroll />}
|
||||
</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 { describe, expect, it, vi } from 'vitest';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useScrollToUserMessage } from './useScrollToUserMessage';
|
||||
|
||||
describe('useScrollToUserMessage', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
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 { rerender } = renderHook(
|
||||
@@ -29,9 +37,14 @@ describe('useScrollToUserMessage', () => {
|
||||
isSecondLastMessageFromUser: true,
|
||||
});
|
||||
|
||||
expect(scrollToIndex).toHaveBeenCalledTimes(1);
|
||||
// Should scroll to index 2 (dataSourceLength - 2 = 4 - 2 = 2, the user message)
|
||||
expect(scrollToIndex).toHaveBeenCalledWith(2, { align: 'start', smooth: true });
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
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', () => {
|
||||
@@ -58,8 +71,11 @@ describe('useScrollToUserMessage', () => {
|
||||
isSecondLastMessageFromUser: true,
|
||||
});
|
||||
|
||||
// Should scroll to index 4 (dataSourceLength - 2 = 6 - 2 = 4)
|
||||
expect(scrollToIndex).toHaveBeenCalledWith(4, { align: 'start', smooth: true });
|
||||
act(() => {
|
||||
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 {
|
||||
/**
|
||||
@@ -32,6 +34,18 @@ export function useScrollToUserMessage({
|
||||
scrollToIndex,
|
||||
}: UseScrollToUserMessageOptions): void {
|
||||
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(() => {
|
||||
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)
|
||||
if (newMessageCount === 2 && isSecondLastMessageFromUser && scrollToIndex) {
|
||||
// Scroll to the second-to-last message (user's message) with the start aligned
|
||||
scrollToIndex(dataSourceLength - 2, { align: 'start', smooth: true });
|
||||
const userMessageIndex = dataSourceLength - 2;
|
||||
|
||||
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 { ChatItem } from '@/features/Conversation/ChatItem';
|
||||
import { useNewScreen } from '@/features/Conversation/Messages/components/useNewScreen';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
|
||||
|
||||
@@ -34,108 +33,97 @@ interface AssistantMessageProps {
|
||||
isLatestItem?: boolean;
|
||||
}
|
||||
|
||||
const AssistantMessage = memo<AssistantMessageProps>(
|
||||
({ id, index, disableEditing, isLatestItem }) => {
|
||||
// Get message and actionsConfig from ConversationStore
|
||||
const item = useConversationStore(dataSelectors.getDisplayMessageById(id), isEqual)!;
|
||||
const AssistantMessage = memo<AssistantMessageProps>(({ id, index, disableEditing }) => {
|
||||
// Get message and actionsConfig from ConversationStore
|
||||
const item = useConversationStore(dataSelectors.getDisplayMessageById(id), isEqual)!;
|
||||
|
||||
const {
|
||||
agentId,
|
||||
branch,
|
||||
error,
|
||||
role,
|
||||
content,
|
||||
createdAt,
|
||||
tools,
|
||||
extra,
|
||||
model,
|
||||
provider,
|
||||
performance,
|
||||
usage,
|
||||
metadata,
|
||||
} = item;
|
||||
const {
|
||||
agentId,
|
||||
branch,
|
||||
error,
|
||||
role,
|
||||
content,
|
||||
createdAt,
|
||||
tools,
|
||||
extra,
|
||||
model,
|
||||
provider,
|
||||
performance,
|
||||
usage,
|
||||
metadata,
|
||||
} = item;
|
||||
|
||||
const avatar = useAgentMeta(agentId);
|
||||
const avatar = useAgentMeta(agentId);
|
||||
|
||||
// Get editing and generating state from ConversationStore
|
||||
const editing = useConversationStore(messageStateSelectors.isMessageEditing(id));
|
||||
const generating = useConversationStore(messageStateSelectors.isMessageGenerating(id));
|
||||
const creating = useConversationStore(messageStateSelectors.isMessageCreating(id));
|
||||
const { minHeight } = useNewScreen({
|
||||
creating: creating || generating,
|
||||
isLatestItem,
|
||||
messageId: id,
|
||||
});
|
||||
// Get editing and generating state from ConversationStore
|
||||
const editing = useConversationStore(messageStateSelectors.isMessageEditing(id));
|
||||
const generating = useConversationStore(messageStateSelectors.isMessageGenerating(id));
|
||||
|
||||
const errorContent = useErrorContent(error);
|
||||
const errorContent = useErrorContent(error);
|
||||
|
||||
// remove line breaks in artifact tag to make the ast transform easier
|
||||
const message = !editing ? normalizeThinkTags(processWithArtifact(content)) : content;
|
||||
// remove line breaks in artifact tag to make the ast transform easier
|
||||
const message = !editing ? normalizeThinkTags(processWithArtifact(content)) : content;
|
||||
|
||||
const onDoubleClick = useDoubleClickEdit({ disableEditing, error, id, role });
|
||||
const setMessageItemActionElementPortialContext =
|
||||
useSetMessageItemActionElementPortialContext();
|
||||
const setMessageItemActionTypeContext = useSetMessageItemActionTypeContext();
|
||||
const onDoubleClick = useDoubleClickEdit({ disableEditing, error, id, role });
|
||||
const setMessageItemActionElementPortialContext = useSetMessageItemActionElementPortialContext();
|
||||
const setMessageItemActionTypeContext = useSetMessageItemActionTypeContext();
|
||||
|
||||
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
|
||||
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
|
||||
|
||||
const onMouseEnter: MouseEventHandler<HTMLDivElement> = useCallback(
|
||||
(e) => {
|
||||
setMessageItemActionElementPortialContext(e.currentTarget);
|
||||
setMessageItemActionTypeContext({ id, index, type: 'assistant' });
|
||||
},
|
||||
[id, index, setMessageItemActionElementPortialContext, setMessageItemActionTypeContext],
|
||||
);
|
||||
const onMouseEnter: MouseEventHandler<HTMLDivElement> = useCallback(
|
||||
(e) => {
|
||||
setMessageItemActionElementPortialContext(e.currentTarget);
|
||||
setMessageItemActionTypeContext({ id, index, type: 'assistant' });
|
||||
},
|
||||
[id, index, setMessageItemActionElementPortialContext, setMessageItemActionTypeContext],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChatItem
|
||||
showTitle
|
||||
aboveMessage={null}
|
||||
avatar={avatar}
|
||||
customErrorRender={(error) => <ErrorMessageExtra data={item} error={error} />}
|
||||
editing={editing}
|
||||
id={id}
|
||||
loading={generating}
|
||||
message={message}
|
||||
newScreenMinHeight={minHeight}
|
||||
placement={'left'}
|
||||
time={createdAt}
|
||||
actions={
|
||||
<>
|
||||
{isDevMode && branch && (
|
||||
<MessageBranch
|
||||
activeBranchIndex={branch.activeBranchIndex}
|
||||
count={branch.count}
|
||||
messageId={id}
|
||||
/>
|
||||
)}
|
||||
{actionBarHolder}
|
||||
</>
|
||||
}
|
||||
error={
|
||||
errorContent && error && (message === LOADING_FLAT || !message) ? errorContent : undefined
|
||||
}
|
||||
messageExtra={
|
||||
<AssistantMessageExtra
|
||||
content={content}
|
||||
extra={extra}
|
||||
id={id}
|
||||
model={model!}
|
||||
performance={performance! || metadata}
|
||||
provider={provider!}
|
||||
tools={tools}
|
||||
usage={usage! || metadata}
|
||||
/>
|
||||
}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
>
|
||||
<MessageContent {...item} />
|
||||
</ChatItem>
|
||||
);
|
||||
},
|
||||
isEqual,
|
||||
);
|
||||
return (
|
||||
<ChatItem
|
||||
showTitle
|
||||
aboveMessage={null}
|
||||
avatar={avatar}
|
||||
customErrorRender={(error) => <ErrorMessageExtra data={item} error={error} />}
|
||||
editing={editing}
|
||||
id={id}
|
||||
loading={generating}
|
||||
message={message}
|
||||
placement={'left'}
|
||||
time={createdAt}
|
||||
actions={
|
||||
<>
|
||||
{isDevMode && branch && (
|
||||
<MessageBranch
|
||||
activeBranchIndex={branch.activeBranchIndex}
|
||||
count={branch.count}
|
||||
messageId={id}
|
||||
/>
|
||||
)}
|
||||
{actionBarHolder}
|
||||
</>
|
||||
}
|
||||
error={
|
||||
errorContent && error && (message === LOADING_FLAT || !message) ? errorContent : undefined
|
||||
}
|
||||
messageExtra={
|
||||
<AssistantMessageExtra
|
||||
content={content}
|
||||
extra={extra}
|
||||
id={id}
|
||||
model={model!}
|
||||
performance={performance! || metadata}
|
||||
provider={provider!}
|
||||
tools={tools}
|
||||
usage={usage! || metadata}
|
||||
/>
|
||||
}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
>
|
||||
<MessageContent {...item} />
|
||||
</ChatItem>
|
||||
);
|
||||
}, isEqual);
|
||||
|
||||
AssistantMessage.displayName = 'AssistantMessage';
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { memo, Suspense, useCallback, useMemo } from 'react';
|
||||
|
||||
import { MESSAGE_ACTION_BAR_PORTAL_ATTRIBUTES } from '@/const/messageActionPortal';
|
||||
import { ChatItem } from '@/features/Conversation/ChatItem';
|
||||
import { useNewScreen } from '@/features/Conversation/Messages/components/useNewScreen';
|
||||
import { useOpenChatSettings } from '@/hooks/useInterceptingRoutes';
|
||||
import dynamic from '@/libs/next/dynamic';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
@@ -45,7 +44,7 @@ interface GroupMessageProps {
|
||||
isLatestItem?: boolean;
|
||||
}
|
||||
|
||||
const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLatestItem }) => {
|
||||
const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing }) => {
|
||||
// Get message and actionsConfig from ConversationStore
|
||||
const item = useConversationStore(dataSelectors.getDisplayMessageById(id), isEqual)!;
|
||||
|
||||
@@ -72,13 +71,6 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
|
||||
|
||||
// Get editing state from ConversationStore
|
||||
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 addReaction = useConversationStore((s) => s.addReaction);
|
||||
@@ -136,7 +128,6 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
|
||||
<ChatItem
|
||||
showTitle
|
||||
avatar={avatar}
|
||||
newScreenMinHeight={minHeight}
|
||||
placement={'left'}
|
||||
time={createdAt}
|
||||
actions={
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useTranslation } from 'react-i18next';
|
||||
import { MESSAGE_ACTION_BAR_PORTAL_ATTRIBUTES } from '@/const/messageActionPortal';
|
||||
import AgentGroupAvatar from '@/features/AgentGroupAvatar';
|
||||
import { ChatItem } from '@/features/Conversation/ChatItem';
|
||||
import { useNewScreen } from '@/features/Conversation/Messages/components/useNewScreen';
|
||||
import { useAgentGroupStore } from '@/store/agentGroup';
|
||||
import { agentGroupSelectors } from '@/store/agentGroup/selectors';
|
||||
import { useUserStore } from '@/store/user';
|
||||
@@ -18,7 +17,7 @@ import { userGeneralSettingsSelectors, userProfileSelectors } from '@/store/user
|
||||
|
||||
import { ReactionDisplay } from '../../components/Reaction';
|
||||
import { useAgentMeta } from '../../hooks';
|
||||
import { dataSelectors, messageStateSelectors, useConversationStore } from '../../store';
|
||||
import { dataSelectors, useConversationStore } from '../../store';
|
||||
import Usage from '../components/Extras/Usage';
|
||||
import MessageBranch from '../components/MessageBranch';
|
||||
import {
|
||||
@@ -40,7 +39,7 @@ interface GroupMessageProps {
|
||||
isLatestItem?: boolean;
|
||||
}
|
||||
|
||||
const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLatestItem }) => {
|
||||
const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
// Get message and actionsConfig from ConversationStore
|
||||
@@ -60,14 +59,6 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
|
||||
const groupMeta = useAgentGroupStore(agentGroupSelectors.currentGroupMeta);
|
||||
|
||||
// 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 addReaction = useConversationStore((s) => s.addReaction);
|
||||
const removeReaction = useConversationStore((s) => s.removeReaction);
|
||||
@@ -116,7 +107,6 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
|
||||
<ChatItem
|
||||
showTitle
|
||||
avatar={{ ...avatar, title: groupMeta.title }}
|
||||
newScreenMinHeight={minHeight}
|
||||
placement={'left'}
|
||||
time={createdAt}
|
||||
titleAddon={<Tag>{t('supervisor.label')}</Tag>}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ChatItem } from '@/features/Conversation/ChatItem';
|
||||
import { useNewScreen } from '@/features/Conversation/Messages/components/useNewScreen';
|
||||
import TaskAvatar from '@/features/Conversation/Messages/Tasks/shared/TaskAvatar';
|
||||
import { useOpenChatSettings } from '@/hooks/useInterceptingRoutes';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
@@ -29,7 +28,7 @@ interface TaskMessageProps {
|
||||
isLatestItem?: boolean;
|
||||
}
|
||||
|
||||
const TaskMessage = memo<TaskMessageProps>(({ id, index, disableEditing, isLatestItem }) => {
|
||||
const TaskMessage = memo<TaskMessageProps>(({ id, index, disableEditing }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
// 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
|
||||
const editing = useConversationStore(messageStateSelectors.isMessageEditing(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);
|
||||
|
||||
@@ -83,7 +76,6 @@ const TaskMessage = memo<TaskMessageProps>(({ id, index, disableEditing, isLates
|
||||
id={id}
|
||||
loading={generating}
|
||||
message={message}
|
||||
newScreenMinHeight={minHeight}
|
||||
placement={'left'}
|
||||
time={createdAt}
|
||||
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
|
||||
*/
|
||||
export interface VirtuaScrollMethods {
|
||||
getItemOffset: (index: number) => number;
|
||||
getItemSize: (index: number) => number;
|
||||
getScrollOffset: () => number;
|
||||
getScrollSize: () => number;
|
||||
getViewportSize: () => number;
|
||||
scrollTo: (offset: number) => void;
|
||||
scrollToIndex: (
|
||||
index: number,
|
||||
options?: { align?: 'start' | 'center' | 'end'; smooth?: boolean },
|
||||
|
||||
Reference in New Issue
Block a user