Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions gui/src/components/StepContainer/StepContainer.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -137,3 +137,5 @@ export default function StepContainer(props: StepContainerProps) {
</div>
);
}

export default memo(StepContainer);
6 changes: 3 additions & 3 deletions gui/src/components/gui/TimelineItem.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
) : (
Expand All @@ -50,10 +51,9 @@ function TimelineItem(props: TimelineItemProps) {
</CollapseButton>
<span style={{ color: lightGray }}>
{props.item.message.role} Message
{/* {props.step.error ? props.step.error.title : props.step.name} */}
</span>
</CollapsedDiv>
);
}
});

export default TimelineItem;
255 changes: 133 additions & 122 deletions gui/src/pages/gui/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<ContinueInputBox
onEnter={(editorState, modifiers) =>
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 (
<>
<div className="thread-message">
<TimelineItem
item={item}
iconElement={<ChatBubbleOvalLeftIcon width="16px" height="16px" />}
open={true}
onToggle={() => {}}
>
<StepContainer
index={index}
isLast={isLast}
item={item}
latestSummaryIndex={latestSummaryIndex}
/>
</TimelineItem>
</div>
{toolCallStates && (
<ToolCallDiv toolCallStates={toolCallStates} historyIndex={index} />
)}
</>
);
}

if (message.role === "thinking") {
const thinkingContent = renderChatMessage(message);
if (!thinkingContent?.trim()) {
return null;
}
return (
<div className={isBeforeLatestSummary ? "opacity-50" : ""}>
<ThinkingBlockPeek
content={thinkingContent}
redactedThinking={message.redactedThinking}
index={index}
prevItem={prevItem}
inProgress={isLast && isStreaming}
signature={message.signature}
/>
</div>
);
}

return (
<div className="thread-message">
<TimelineItem
item={item}
iconElement={<ChatBubbleOvalLeftIcon width="16px" height="16px" />}
open={true}
onToggle={() => {}}
>
<StepContainer
index={index}
isLast={isLast}
item={item}
latestSummaryIndex={latestSummaryIndex}
/>
</TimelineItem>
</div>
);
});

function fallbackRender({ error, resetErrorBoundary }: any) {
// Call resetErrorBoundary() to reset the error boundary and retry the render.

Expand Down Expand Up @@ -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<HTMLInputElement>(null);
const stepsDivRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -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 (
<ContinueInputBox
onEnter={(editorState, modifiers) =>
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 */}
<div className="thread-message">
<TimelineItem
item={item}
iconElement={
<ChatBubbleOvalLeftIcon width="16px" height="16px" />
}
open={
typeof stepsOpen[index] === "undefined"
? true
: stepsOpen[index]!
}
onToggle={() => {}}
>
<StepContainer
index={index}
isLast={index === history.length - 1}
item={item}
latestSummaryIndex={latestSummaryIndex}
/>
</TimelineItem>
</div>

{toolCallStates && (
<ToolCallDiv
toolCallStates={toolCallStates}
historyIndex={index}
/>
)}
</>
);
}

if (message.role === "thinking") {
const thinkingContent = renderChatMessage(message);
if (!thinkingContent?.trim()) {
return null;
}
return (
<div className={isBeforeLatestSummary ? "opacity-50" : ""}>
<ThinkingBlockPeek
content={thinkingContent}
redactedThinking={message.redactedThinking}
index={index}
prevItem={index > 0 ? history[index - 1] : null}
inProgress={index === history.length - 1 && isStreaming}
signature={message.signature}
/>
</div>
);
}
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 (
<div className="thread-message">
<TimelineItem
item={item}
iconElement={<ChatBubbleOvalLeftIcon width="16px" height="16px" />}
open={
typeof stepsOpen[index] === "undefined" ? true : stepsOpen[index]!
}
onToggle={() => {}}
>
<StepContainer
index={index}
isLast={index === history.length - 1}
item={item}
latestSummaryIndex={latestSummaryIndex}
/>
</TimelineItem>
</div>
);
},
[sendInput, isLastUserInput, history, stepsOpen, isStreaming],
const latestSummaryIndex = useMemo(
() => findLatestSummaryIndex(history),
[history],
);

const showScrollbar = showChatScrollbar ?? window.innerHeight > 5000;
Expand Down Expand Up @@ -462,7 +464,16 @@ export function Chat() {
dispatch(newSession());
}}
>
{renderChatHistoryItem(item, index)}
<MemoizedHistoryItem
item={item}
index={index}
isLast={index === history.length - 1}
latestSummaryIndex={latestSummaryIndex}
sendInput={sendInput}
isLastUserInputIndex={lastUserInputIndex}
isStreaming={isStreaming}
prevItem={index > 0 ? history[index - 1] : null}
/>
</ErrorBoundary>
{index === history.length - 1 && <InlineErrorMessage />}
</div>
Expand Down
7 changes: 1 addition & 6 deletions gui/src/pages/gui/useAutoScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading