diff --git a/gui/src/components/StepContainer/StepContainer.tsx b/gui/src/components/StepContainer/StepContainer.tsx index fc6981d0558..d6484efeeca 100644 --- a/gui/src/components/StepContainer/StepContainer.tsx +++ b/gui/src/components/StepContainer/StepContainer.tsx @@ -1,6 +1,6 @@ import { ChatHistoryItem } from "core"; import { renderChatMessage, stripImages } from "core/util/messageContent"; -import { useEffect, useState } from "react"; +import { memo, useEffect, useState } from "react"; import { useDispatch } from "react-redux"; import { useAppSelector } from "../../redux/hooks"; import { selectUIConfig } from "../../redux/slices/configSlice"; @@ -18,7 +18,7 @@ interface StepContainerProps { latestSummaryIndex?: number; } -export default function StepContainer(props: StepContainerProps) { +function StepContainer(props: StepContainerProps) { const dispatch = useDispatch(); const [isTruncated, setIsTruncated] = useState(false); const isStreaming = useAppSelector((state) => state.session.isStreaming); @@ -137,3 +137,5 @@ export default function StepContainer(props: StepContainerProps) { ); } + +export default memo(StepContainer); diff --git a/gui/src/components/gui/TimelineItem.tsx b/gui/src/components/gui/TimelineItem.tsx index c3edc4c7663..efe3bcda271 100644 --- a/gui/src/components/gui/TimelineItem.tsx +++ b/gui/src/components/gui/TimelineItem.tsx @@ -1,5 +1,6 @@ import { ChatBubbleOvalLeftIcon } from "@heroicons/react/24/outline"; import { ChatHistoryItem } from "core"; +import { memo } from "react"; import styled from "styled-components"; import { lightGray, vscBackground } from ".."; import { getFontSize } from "../../util"; @@ -34,7 +35,7 @@ interface TimelineItemProps { iconElement?: JSX.Element; } -function TimelineItem(props: TimelineItemProps) { +const TimelineItem = memo(function TimelineItem(props: TimelineItemProps) { return props.open ? ( props.children ) : ( @@ -50,10 +51,9 @@ function TimelineItem(props: TimelineItemProps) { {props.item.message.role} Message - {/* {props.step.error ? props.step.error.title : props.step.name} */} ); -} +}); export default TimelineItem; diff --git a/gui/src/pages/gui/Chat.tsx b/gui/src/pages/gui/Chat.tsx index d37ff2efcef..d69307d412b 100644 --- a/gui/src/pages/gui/Chat.tsx +++ b/gui/src/pages/gui/Chat.tsx @@ -6,6 +6,7 @@ import { Editor, JSONContent } from "@tiptap/react"; import { ChatHistoryItem, InputModifiers } from "core"; import { renderChatMessage } from "core/util/messageContent"; import { + memo, useCallback, useContext, useEffect, @@ -85,6 +86,119 @@ const StepsDiv = styled.div` export const MAIN_EDITOR_INPUT_ID = "main-editor-input"; +interface HistoryItemRenderProps { + item: ChatHistoryItemWithMessageId; + index: number; + isLast: boolean; + latestSummaryIndex: number; + sendInput: ( + editorState: JSONContent, + modifiers: InputModifiers, + index?: number, + ) => void; + isLastUserInputIndex: number; + isStreaming: boolean; + prevItem: ChatHistoryItemWithMessageId | null; +} + +const MemoizedHistoryItem = memo(function MemoizedHistoryItem({ + item, + index, + isLast, + latestSummaryIndex, + sendInput, + isLastUserInputIndex, + isStreaming, + prevItem, +}: HistoryItemRenderProps) { + const { message, editorState, contextItems, appliedRules, toolCallStates } = + item; + const isBeforeLatestSummary = + latestSummaryIndex !== -1 && index < latestSummaryIndex; + + if (message.role === "user") { + return ( + + sendInput(editorState, modifiers, index) + } + isLastUserInput={index === isLastUserInputIndex} + isMainInput={false} + editorState={editorState ?? message.content} + contextItems={contextItems} + appliedRules={appliedRules} + inputId={message.id} + /> + ); + } + + if (message.role === "tool") { + return null; + } + + if (message.role === "assistant") { + return ( + <> +
+ } + open={true} + onToggle={() => {}} + > + + +
+ {toolCallStates && ( + + )} + + ); + } + + if (message.role === "thinking") { + const thinkingContent = renderChatMessage(message); + if (!thinkingContent?.trim()) { + return null; + } + return ( +
+ +
+ ); + } + + return ( +
+ } + open={true} + onToggle={() => {}} + > + + +
+ ); +}); + function fallbackRender({ error, resetErrorBoundary }: any) { // Call resetErrorBoundary() to reset the error boundary and retry the render. @@ -114,7 +228,6 @@ export function Chat() { (store) => store.config.config.ui?.showSessionTabs, ); const isStreaming = useAppSelector((state) => state.session.isStreaming); - const [stepsOpen] = useState<(boolean | undefined)[]>([]); const [isCreatingAgent, setIsCreatingAgent] = useState(false); const mainTextInputRef = useRef(null); const stepsDivRef = useRef(null); @@ -312,127 +425,16 @@ export function Chat() { [dispatch], ); - const isLastUserInput = useCallback( - (index: number): boolean => { - return !history - .slice(index + 1) - .some((entry) => entry.message.role === "user"); - }, - [history], - ); - - const renderChatHistoryItem = useCallback( - (item: ChatHistoryItemWithMessageId, index: number) => { - const { - message, - editorState, - contextItems, - appliedRules, - toolCallStates, - } = item; - - // Calculate once for the entire function - const latestSummaryIndex = findLatestSummaryIndex(history); - const isBeforeLatestSummary = - latestSummaryIndex !== -1 && index < latestSummaryIndex; - - if (message.role === "user") { - return ( - - sendInput(editorState, modifiers, index) - } - isLastUserInput={isLastUserInput(index)} - isMainInput={false} - editorState={editorState ?? item.message.content} - contextItems={contextItems} - appliedRules={appliedRules} - inputId={message.id} - /> - ); - } - - if (message.role === "tool") { - return null; - } - - if (message.role === "assistant") { - return ( - <> - {/* Always render assistant content through normal path */} -
- - } - open={ - typeof stepsOpen[index] === "undefined" - ? true - : stepsOpen[index]! - } - onToggle={() => {}} - > - - -
- - {toolCallStates && ( - - )} - - ); - } - - if (message.role === "thinking") { - const thinkingContent = renderChatMessage(message); - if (!thinkingContent?.trim()) { - return null; - } - return ( -
- 0 ? history[index - 1] : null} - inProgress={index === history.length - 1 && isStreaming} - signature={message.signature} - /> -
- ); - } + const lastUserInputIndex = useMemo(() => { + for (let i = history.length - 1; i >= 0; i--) { + if (history[i].message.role === "user") return i; + } + return -1; + }, [history.length]); - // Default case - regular assistant message - return ( -
- } - open={ - typeof stepsOpen[index] === "undefined" ? true : stepsOpen[index]! - } - onToggle={() => {}} - > - - -
- ); - }, - [sendInput, isLastUserInput, history, stepsOpen, isStreaming], + const latestSummaryIndex = useMemo( + () => findLatestSummaryIndex(history), + [history], ); const showScrollbar = showChatScrollbar ?? window.innerHeight > 5000; @@ -462,7 +464,16 @@ export function Chat() { dispatch(newSession()); }} > - {renderChatHistoryItem(item, index)} + 0 ? history[index - 1] : null} + /> {index === history.length - 1 && } diff --git a/gui/src/pages/gui/useAutoScroll.ts b/gui/src/pages/gui/useAutoScroll.ts index 236ca9f7694..63cfe9f27df 100644 --- a/gui/src/pages/gui/useAutoScroll.ts +++ b/gui/src/pages/gui/useAutoScroll.ts @@ -45,14 +45,9 @@ export const useAutoScroll = ( ref.current.addEventListener("scroll", handleScroll); - // Observe the container + // Observe the container — its scrollHeight changes whenever children grow resizeObserver.observe(ref.current); - // Observe all immediate children - Array.from(ref.current.children).forEach((child) => { - resizeObserver.observe(child); - }); - return () => { resizeObserver.disconnect(); ref.current?.removeEventListener("scroll", handleScroll);