♻️ 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:
Innei
2026-03-17 21:19:58 +08:00
committed by GitHub
parent d2d9e6034e
commit b2122a5224
17 changed files with 404 additions and 573 deletions

View File

@@ -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'],
}, },
}, },
}; };

View File

@@ -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: () => {

View File

@@ -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,
}} }}

View File

@@ -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'];
/** /**

View File

@@ -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);

View File

@@ -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>
); );
}} }}

View File

@@ -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__');
});
});

View File

@@ -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,
};
};

View File

@@ -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 });
}); });
}); });

View File

@@ -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]);
} }

View File

@@ -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';

View File

@@ -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={

View File

@@ -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>}

View File

@@ -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>}

View File

@@ -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');
});
});
});

View File

@@ -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 };
};

View File

@@ -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 },