Skip to content
Draft
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
142 changes: 37 additions & 105 deletions web-ui/src/components/detail-panels/cline-agent-chat-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,31 @@ import type { ClineChatMessage } from "@/hooks/use-cline-chat-session";
import type { RuntimeTaskHookActivity, RuntimeTaskSessionSummary } from "@/runtime/types";
import { resetWorkspaceMetadataStore, setTaskWorkspaceSnapshot } from "@/stores/workspace-metadata-store";

vi.mock("react-virtuoso", () => ({
Virtuoso: ({
data,
itemContent,
components,
context,
className,
style,
}: {
data: unknown[];
itemContent: (index: number, item: unknown) => React.ReactNode;
components?: { Footer?: React.ComponentType<{ context?: unknown }> };
context?: unknown;
className?: string;
style?: React.CSSProperties;
}) => (
<div className={`overflow-y-auto ${className ?? ""}`} style={style}>
{data.map((item, index) => (
<div key={index}>{itemContent(index, item)}</div>
))}
{components?.Footer ? <components.Footer context={context} /> : null}
</div>
),
}));

function createSummary(
state: RuntimeTaskSessionSummary["state"],
latestHookActivity: RuntimeTaskHookActivity | null = null,
Expand Down Expand Up @@ -36,54 +61,6 @@ function renderPanel(root: Root, panel: ReactElement): void {
root.render(<TooltipProvider>{panel}</TooltipProvider>);
}

function getMessageList(container: HTMLElement): HTMLDivElement {
const messageList = container.querySelector("div.overflow-y-auto");
expect(messageList).toBeInstanceOf(HTMLDivElement);
if (!(messageList instanceof HTMLDivElement)) {
throw new Error("Expected chat message list.");
}
return messageList;
}

function mockScrollMetrics(
element: HTMLDivElement,
initialValues: { scrollHeight: number; clientHeight: number; scrollTop: number },
): {
getScrollTop: () => number;
setScrollHeight: (value: number) => void;
setScrollTop: (value: number) => void;
} {
let currentScrollHeight = initialValues.scrollHeight;
const currentClientHeight = initialValues.clientHeight;
let currentScrollTop = initialValues.scrollTop;

Object.defineProperty(element, "scrollHeight", {
configurable: true,
get: () => currentScrollHeight,
});
Object.defineProperty(element, "clientHeight", {
configurable: true,
get: () => currentClientHeight,
});
Object.defineProperty(element, "scrollTop", {
configurable: true,
get: () => currentScrollTop,
set: (value: number) => {
currentScrollTop = value;
},
});

return {
getScrollTop: () => currentScrollTop,
setScrollHeight: (value: number) => {
currentScrollHeight = value;
},
setScrollTop: (value: number) => {
currentScrollTop = value;
},
};
}

describe("ClineAgentChatPanel", () => {
let container: HTMLDivElement;
let root: Root;
Expand Down Expand Up @@ -385,7 +362,7 @@ describe("ClineAgentChatPanel", () => {
expect(image).toBeInstanceOf(HTMLImageElement);
});

it("keeps the message list pinned to the bottom while new content streams in", async () => {
it("renders newly streamed messages as they arrive", async () => {
const initialMessages: ClineChatMessage[] = [
{
id: "assistant-1",
Expand All @@ -409,13 +386,7 @@ describe("ClineAgentChatPanel", () => {
await Promise.resolve();
});

const messageList = getMessageList(container);
const scroll = mockScrollMetrics(messageList, {
scrollHeight: 200,
clientHeight: 100,
scrollTop: 100,
});
scroll.setScrollHeight(260);
expect(container.textContent).toContain("First reply");

await act(async () => {
renderPanel(
Expand All @@ -430,10 +401,11 @@ describe("ClineAgentChatPanel", () => {
await Promise.resolve();
});

expect(scroll.getScrollTop()).toBe(260);
expect(container.textContent).toContain("First reply");
expect(container.textContent).toContain("Second reply");
});

it("stops auto-scroll while the user is reading older messages and re-enables it at the bottom", async () => {
it("renders all messages across multiple content updates", async () => {
const initialMessages: ClineChatMessage[] = [
{
id: "assistant-1",
Expand Down Expand Up @@ -463,20 +435,6 @@ describe("ClineAgentChatPanel", () => {
await Promise.resolve();
});

const messageList = getMessageList(container);
const scroll = mockScrollMetrics(messageList, {
scrollHeight: 200,
clientHeight: 100,
scrollTop: 100,
});

scroll.setScrollTop(20);
await act(async () => {
messageList.dispatchEvent(new Event("scroll", { bubbles: true }));
await Promise.resolve();
});

scroll.setScrollHeight(260);
await act(async () => {
renderPanel(
root,
Expand All @@ -490,15 +448,6 @@ describe("ClineAgentChatPanel", () => {
await Promise.resolve();
});

expect(scroll.getScrollTop()).toBe(20);

scroll.setScrollTop(160);
await act(async () => {
messageList.dispatchEvent(new Event("scroll", { bubbles: true }));
await Promise.resolve();
});

scroll.setScrollHeight(320);
await act(async () => {
renderPanel(
root,
Expand All @@ -512,7 +461,9 @@ describe("ClineAgentChatPanel", () => {
await Promise.resolve();
});

expect(scroll.getScrollTop()).toBe(320);
expect(container.textContent).toContain("First reply");
expect(container.textContent).toContain("Second reply");
expect(container.textContent).toContain("Third reply");
});

it("shows the thinking indicator while assistant text is streaming", async () => {
Expand Down Expand Up @@ -1069,7 +1020,7 @@ describe("ClineAgentChatPanel", () => {
expect(onSendMessage).toHaveBeenCalledWith("task-1", "Keep acting", { mode: "act" });
});

it("keeps chat pinned to bottom when action footer appears", async () => {
it("renders action footer buttons when task enters review state", async () => {
const messages: ClineChatMessage[] = [
{
id: "assistant-1",
Expand All @@ -1089,27 +1040,6 @@ describe("ClineAgentChatPanel", () => {
deletions: 1,
});

await act(async () => {
renderPanel(
root,
<ClineAgentChatPanel
taskId="task-1"
summary={createSummary("awaiting_review")}
onLoadMessages={async () => messages}
showMoveToTrash={false}
/>,
);
await Promise.resolve();
});

const messageList = getMessageList(container);
const scroll = mockScrollMetrics(messageList, {
scrollHeight: 200,
clientHeight: 100,
scrollTop: 100,
});
scroll.setScrollHeight(240);

await act(async () => {
renderPanel(
root,
Expand All @@ -1127,7 +1057,9 @@ describe("ClineAgentChatPanel", () => {
await Promise.resolve();
});

expect(scroll.getScrollTop()).toBe(240);
expect(container.textContent).toContain("Commit");
expect(container.textContent).toContain("Open PR");
expect(container.textContent).toContain("Move Card To Done");
});

it("does not show commit actions when the review workspace is clean", async () => {
Expand Down
83 changes: 37 additions & 46 deletions web-ui/src/components/detail-panels/cline-agent-chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import React, {
useRef,
useState,
} from "react";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";

import { ClineChatComposer } from "@/components/detail-panels/cline-chat-composer";
import { ClineChatMessageItem } from "@/components/detail-panels/cline-chat-message-item";
Expand Down Expand Up @@ -56,6 +57,21 @@ const ClineCreditLimitNotice = React.memo(function ClineCreditLimitNotice() {
);
});

type ChatFooterContext = {
showAgentProgressIndicator: boolean;
isCreditLimitNoticeVisible: boolean;
};

const ChatFooter = React.memo(function ChatFooter({ context }: { context?: ChatFooterContext }) {
if (!context) return null;
return (
<>
{context.showAgentProgressIndicator ? <ClineThinkingIndicator /> : null}
{context.isCreditLimitNoticeVisible ? <ClineCreditLimitNotice /> : null}
</>
);
});

export interface ClineAgentChatPanelHandle {
appendToDraft: (text: string) => void;
sendText: (text: string) => Promise<void>;
Expand Down Expand Up @@ -141,7 +157,6 @@ export const ClineAgentChatPanel = React.forwardRef<ClineAgentChatPanelHandle, C
showReviewActions,
showAgentProgressIndicator,
showActionFooter,
showCancelAutomaticAction,
handleSendText,
handleSendDraft,
handleCancelTurn,
Expand All @@ -161,11 +176,10 @@ export const ClineAgentChatPanel = React.forwardRef<ClineAgentChatPanelHandle, C
cancelAutomaticActionLabel,
showMoveToTrash,
});
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const virtuosoRef = useRef<VirtuosoHandle>(null);
// TODO: Persist per-task mode immediately when toggled so page refresh restores unsent mode changes.
const modeByTaskIdRef = useRef<Map<string, RuntimeTaskSessionMode>>(new Map());
const [composerError, setComposerError] = useState<string | null>(null);
const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true);
const [isSavingModel, setIsSavingModel] = useState(false);
const isCreditLimitNoticeVisible = summary?.latestHookActivity?.notificationType === "credit_limit";
const [mode, setMode] = useState<RuntimeTaskSessionMode>(() => {
Expand Down Expand Up @@ -222,45 +236,16 @@ export const ClineAgentChatPanel = React.forwardRef<ClineAgentChatPanelHandle, C
? "The selected Cline model may not accept image input. Choose a vision-capable model to use these images."
: null;

const isPinnedToBottom = useCallback((container: HTMLDivElement): boolean => {
const remainingDistance = container.scrollHeight - container.scrollTop - container.clientHeight;
return remainingDistance <= BOTTOM_LOCK_THRESHOLD_PX;
}, []);

const handleMessageListScroll = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) {
return;
}
const nextIsAutoScrollEnabled = isPinnedToBottom(container);
setIsAutoScrollEnabled((currentValue) =>
currentValue === nextIsAutoScrollEnabled ? currentValue : nextIsAutoScrollEnabled,
);
}, [isPinnedToBottom]);

useLayoutEffect(() => {
const container = scrollContainerRef.current;
if (!container || !isAutoScrollEnabled) {
return;
if (showActionFooter) {
virtuosoRef.current?.scrollToIndex({ index: "LAST", behavior: "auto" });
}
container.scrollTop = container.scrollHeight;
}, [
isAutoScrollEnabled,
messages,
showAgentProgressIndicator,
showActionFooter,
showReviewActions,
showCancelAutomaticAction,
]);
}, [showActionFooter]);

useEffect(() => {
setComposerError(null);
}, [taskId]);

useEffect(() => {
setIsAutoScrollEnabled(true);
}, [taskId]);

useEffect(() => {
const persistedMode = modeByTaskIdRef.current.get(taskId);
const nextMode = persistedMode ?? summary?.mode ?? defaultMode;
Expand Down Expand Up @@ -412,17 +397,23 @@ export const ClineAgentChatPanel = React.forwardRef<ClineAgentChatPanelHandle, C

return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div
ref={scrollContainerRef}
className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto px-2 py-3"
onScroll={handleMessageListScroll}
>
{messages.map((message) => (
<ClineChatMessageItem key={message.id} message={message} />
))}
{showAgentProgressIndicator ? <ClineThinkingIndicator /> : null}
{isCreditLimitNoticeVisible ? <ClineCreditLimitNotice /> : null}
</div>
<Virtuoso
key={taskId}
ref={virtuosoRef}
className="min-w-0 flex-1"
data={messages}
initialTopMostItemIndex={messages.length > 0 ? messages.length - 1 : 0}
computeItemKey={(_, message) => message.id}
itemContent={(_, message) => (
<div className="px-2 py-1">
<ClineChatMessageItem message={message} />
</div>
)}
followOutput="smooth"
atBottomThreshold={BOTTOM_LOCK_THRESHOLD_PX}
context={{ showAgentProgressIndicator, isCreditLimitNoticeVisible }}
components={{ Footer: ChatFooter }}
/>
{panelError ? (
<div className="border-t border-status-red/30 bg-status-red/10 px-2 py-2 text-xs text-status-red">
{panelError}
Expand Down
10 changes: 7 additions & 3 deletions web-ui/src/components/detail-panels/cline-chat-message-item.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as Collapsible from "@radix-ui/react-collapsible";
import { Brain, ChevronDown, ChevronRight, XCircle } from "lucide-react";
import { type ReactElement, useEffect, useMemo, useRef, useState } from "react";
import React, { type ReactElement, useEffect, useMemo, useRef, useState } from "react";
import {
formatToolInputForDisplay,
getToolDisplay,
Expand Down Expand Up @@ -175,7 +175,11 @@ function ReasoningMessageBlock({ message }: { message: ClineChatMessage }): Reac
);
}

export function ClineChatMessageItem({ message }: { message: ClineChatMessage }): ReactElement {
export const ClineChatMessageItem = React.memo(function ClineChatMessageItem({
message,
}: {
message: ClineChatMessage;
}): ReactElement {
if (message.role === "tool") {
return <ToolMessageBlock message={message} />;
}
Expand Down Expand Up @@ -209,4 +213,4 @@ export function ClineChatMessageItem({ message }: { message: ClineChatMessage })
{message.content}
</div>
);
}
});