diff --git a/apps/web/src/components/sme/SmeChatWorkspace.tsx b/apps/web/src/components/sme/SmeChatWorkspace.tsx index 8c9679743..ac3bfddc4 100644 --- a/apps/web/src/components/sme/SmeChatWorkspace.tsx +++ b/apps/web/src/components/sme/SmeChatWorkspace.tsx @@ -13,11 +13,9 @@ import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; import { toastManager } from "~/components/ui/toast"; import { SmeConversationDialog } from "./SmeConversationDialog"; -import { SmeMessageBubble } from "./SmeMessageBubble"; +import { SmeMessageList } from "./SmeMessageList"; import { getSmeAuthMethodLabel, SME_PROVIDER_LABELS } from "./smeConversationConfig"; -const EMPTY_MESSAGES: SmeMessage[] = []; - interface SmeChatWorkspaceProps { conversationId: string | null; onToggleKnowledge: () => void; @@ -36,17 +34,9 @@ export function SmeChatWorkspace({ () => conversations.find((item) => item.conversationId === conversationId) ?? null, [conversationId, conversations], ); - const messages = useSmeStore((state) => - conversationId - ? (state.messagesByConversation[conversationId] ?? EMPTY_MESSAGES) - : EMPTY_MESSAGES, - ); const conversationError = useSmeStore((state) => conversationId ? state.errorsByConversation[conversationId] : undefined, ); - const streamingConversationId = useSmeStore((state) => state.streamingConversationId); - const streamingMessageId = useSmeStore((state) => state.streamingMessageId); - const streamingText = useSmeStore((state) => state.streamingText); const addUserMessage = useSmeStore((state) => state.addUserMessage); const clearStream = useSmeStore((state) => state.clearStream); const setMessages = useSmeStore((state) => state.setMessages); @@ -55,7 +45,6 @@ export function SmeChatWorkspace({ const [sending, setSending] = useState(false); const [dialogOpen, setDialogOpen] = useState(false); const [bannerDismissed, setBannerDismissed] = useState(false); - const messagesEndRef = useRef(null); const textareaRef = useRef(null); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const validationQuery = useQuery({ @@ -96,10 +85,6 @@ export function SmeChatWorkspace({ setBannerDismissed(false); }, [conversationId]); - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages, streamingText]); - useEffect(() => { const textarea = textareaRef.current; if (!textarea) return; @@ -116,7 +101,7 @@ export function SmeChatWorkspace({ setInputText(""); setSending(true); setConversationError(conversationId, undefined); - const previousMessages = messages; + const previousMessages = useSmeStore.getState().messagesByConversation[conversationId] ?? []; addUserMessage(conversationId, { messageId: `temp-${Date.now()}` as SmeMessageId, @@ -174,7 +159,6 @@ export function SmeChatWorkspace({ conversation, conversationId, inputText, - messages, providerOptions, sendDisabled, setConversationError, @@ -289,47 +273,7 @@ export function SmeChatWorkspace({ ) : null} -
-
- {messages.map((message) => ( - - ))} - {streamingConversationId === conversationId && streamingText ? ( - - ) : null} - {sending && !streamingText ? ( -
-
- -
-
-

SME Assistant

-
-
- - - -
- Thinking... -
-
-
- ) : null} -
-
-
+
diff --git a/apps/web/src/components/sme/SmeMessageBubble.tsx b/apps/web/src/components/sme/SmeMessageBubble.tsx index 9534e9ea3..717ec1adc 100644 --- a/apps/web/src/components/sme/SmeMessageBubble.tsx +++ b/apps/web/src/components/sme/SmeMessageBubble.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense } from "react"; +import { lazy, memo, Suspense } from "react"; import { UserIcon, SparklesIcon } from "lucide-react"; import type { SmeMessage } from "@okcode/contracts"; @@ -10,8 +10,9 @@ interface SmeMessageBubbleProps { message: SmeMessage; } -export function SmeMessageBubble({ message }: SmeMessageBubbleProps) { +export const SmeMessageBubble = memo(function SmeMessageBubble({ message }: SmeMessageBubbleProps) { const isUser = message.role === "user"; + const renderPlainText = isUser || Boolean(message.isStreaming); return (
- {isUser ? ( + {renderPlainText ? (
{message.text}
) : (
); -} +}); diff --git a/apps/web/src/components/sme/SmeMessageList.tsx b/apps/web/src/components/sme/SmeMessageList.tsx new file mode 100644 index 000000000..543bc2548 --- /dev/null +++ b/apps/web/src/components/sme/SmeMessageList.tsx @@ -0,0 +1,100 @@ +import { memo, useEffect, useRef } from "react"; +import type { SmeConversationId, SmeMessage, SmeMessageId } from "@okcode/contracts"; + +import { isScrollContainerNearBottom } from "~/chat-scroll"; +import { useSmeStore } from "~/smeStore"; + +import { SmeMessageBubble } from "./SmeMessageBubble"; + +const EMPTY_MESSAGES: SmeMessage[] = []; + +interface SmeMessageListProps { + conversationId: string; + sending: boolean; +} + +export const SmeMessageList = memo(function SmeMessageList({ + conversationId, + sending, +}: SmeMessageListProps) { + const messages = useSmeStore( + (state) => state.messagesByConversation[conversationId] ?? EMPTY_MESSAGES, + ); + const streamingConversationId = useSmeStore((state) => state.streamingConversationId); + const streamingMessageId = useSmeStore((state) => state.streamingMessageId); + const streamingText = useSmeStore((state) => state.streamingText); + const scrollContainerRef = useRef(null); + const messagesEndRef = useRef(null); + const shouldAutoScrollRef = useRef(true); + + useEffect(() => { + const scrollContainer = scrollContainerRef.current; + if (!scrollContainer) return; + + shouldAutoScrollRef.current = isScrollContainerNearBottom(scrollContainer); + + const handleScroll = () => { + shouldAutoScrollRef.current = isScrollContainerNearBottom(scrollContainer); + }; + + scrollContainer.addEventListener("scroll", handleScroll, { passive: true }); + return () => { + scrollContainer.removeEventListener("scroll", handleScroll); + }; + }, []); + + useEffect(() => { + if (!shouldAutoScrollRef.current) { + return; + } + + messagesEndRef.current?.scrollIntoView({ + behavior: streamingConversationId === conversationId ? "auto" : "smooth", + block: "end", + }); + }, [conversationId, messages, streamingConversationId, streamingText]); + + return ( +
+
+ {messages.map((message) => ( + + ))} + {streamingConversationId === conversationId && streamingText ? ( + + ) : null} + {sending && streamingConversationId !== conversationId ? ( +
+
+
+
+
+

SME Assistant

+
+
+ + + +
+ Thinking... +
+
+
+ ) : null} +
+
+
+ ); +});