Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/quiet-scroll-lock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Prevent chat auto-scroll from jumping while you read older messages.
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@
padding: 0;
display: flex;
flex-direction: column;
position: relative; /* kilocode_change */
}

.am-messages-list {
Expand All @@ -401,6 +402,27 @@
overflow-y: auto !important;
}

/* Scroll-to-bottom button */
.am-scroll-to-bottom {
/* kilocode_change */
position: absolute;
right: 16px;
bottom: 12px;
z-index: 5;
}

.am-scroll-to-bottom-btn {
/* kilocode_change */
width: 28px;
height: 28px;
border-radius: 999px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
}

.am-message-item {
padding: 10px 20px;
/* border-bottom: 1px solid var(--vscode-editorGroup-border); -- Removed for cleaner look */
Expand Down
35 changes: 31 additions & 4 deletions webview-ui/src/kilocode/agent-manager/components/MessageList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useCallback, useMemo } from "react"
import React, { useEffect, useRef, useCallback, useMemo, useState } from "react"
import { useAtomValue, useSetAtom } from "jotai"
import { useTranslation } from "react-i18next"
import { Virtuoso, VirtuosoHandle } from "react-virtuoso"
Expand All @@ -21,6 +21,7 @@ import { ProgressIndicator } from "./ProgressIndicator"
import { ReasoningBlock } from "./ReasoningBlock"
import MessageThumbnails from "./MessageThumbnails"
import { vscode } from "../utils/vscode"
import { StandardTooltip } from "../../../components/ui" // kilocode_change
import {
MessageCircle,
MessageCircleQuestion,
Expand All @@ -31,6 +32,7 @@ import {
User,
Clock,
Loader,
ChevronDown,
} from "lucide-react"
import { cn } from "../../../lib/utils"

Expand Down Expand Up @@ -62,13 +64,15 @@ function extractCommandMetadata(msg: ClineMessage): { exitCode?: number; status?
*/
export function MessageList({ sessionId }: MessageListProps) {
const { t } = useTranslation("agentManager")
const { t: tChat } = useTranslation("chat") // kilocode_change
const messages = useAtomValue(sessionMessagesAtomFamily(sessionId))
const queue = useAtomValue(sessionMessageQueueAtomFamily(sessionId))
const sendingMessageId = useAtomValue(sessionSendingMessageIdAtomFamily(sessionId))
const setInputValue = useSetAtom(sessionInputAtomFamily(sessionId))
const retryFailedMessage = useSetAtom(retryFailedMessageAtom)
const removeFromQueue = useSetAtom(removeFromQueueAtom)
const virtuosoRef = useRef<VirtuosoHandle>(null)
const [isAtBottom, setIsAtBottom] = useState(true) // kilocode_change

// Combine command and command_output messages into single entries
const combinedMessages = useMemo(() => combineCommandSequences(messages), [messages])
Expand Down Expand Up @@ -99,13 +103,13 @@ export function MessageList({ sessionId }: MessageListProps) {

// Auto-scroll to bottom when new messages arrive using Virtuoso API
useEffect(() => {
if (combinedMessages.length > 0) {
if (isAtBottom && combinedMessages.length > 0) {
virtuosoRef.current?.scrollToIndex({
index: combinedMessages.length - 1,
behavior: "smooth",
})
}
}, [combinedMessages.length])
}, [combinedMessages.length, isAtBottom])

const handleSuggestionClick = useCallback(
(suggestion: SuggestionItem) => {
Expand Down Expand Up @@ -143,6 +147,7 @@ export function MessageList({ sessionId }: MessageListProps) {
const allItems = useMemo(() => {
return [...combinedMessages, ...queue.map((q) => ({ type: "queued" as const, data: q }))]
}, [combinedMessages, queue])
const showScrollToBottom = !isAtBottom && allItems.length > 0 // kilocode_change

// Item content renderer for Virtuoso
const itemContent = useCallback(
Expand Down Expand Up @@ -202,10 +207,32 @@ export function MessageList({ sessionId }: MessageListProps) {
ref={virtuosoRef}
data={allItems}
itemContent={itemContent}
followOutput="smooth"
followOutput={isAtBottom ? "smooth" : false} // kilocode_change
atBottomStateChange={setIsAtBottom} // kilocode_change
increaseViewportBy={{ top: 400, bottom: 400 }}
className="am-messages-list"
/>
{showScrollToBottom && (
<div className="am-scroll-to-bottom">
{" "}
{/* kilocode_change */}
<StandardTooltip content={tChat("scrollToBottom")}>
<button
type="button"
className="am-btn am-btn-secondary am-scroll-to-bottom-btn"
aria-label={tChat("scrollToBottom")}
onClick={() => {
if (allItems.length === 0) return
virtuosoRef.current?.scrollToIndex({
index: allItems.length - 1,
behavior: "smooth",
})
}}>
<ChevronDown size={16} />
</button>
</StandardTooltip>
</div>
)}
</div>
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from "react"
import { describe, it, expect, vi } from "vitest"
import { render, screen, fireEvent } from "@testing-library/react"
import { render, screen, fireEvent, act } from "@testing-library/react"
import { Provider, createStore } from "jotai"
import { MessageList } from "../MessageList"
import { sessionMessagesAtomFamily } from "../../state/atoms/messages"
Expand Down Expand Up @@ -31,16 +32,26 @@ vi.mock("../../../../components/ui", () => ({
}))

// Mock react-virtuoso - tracks rendered items for testing
let lastVirtuosoProps: any // kilocode_change
let lastScrollToIndex: ReturnType<typeof vi.fn> | null // kilocode_change
vi.mock("react-virtuoso", () => ({
Virtuoso: ({ data, itemContent }: any) => (
<div data-testid="virtuoso-list" data-item-count={data.length}>
{data.map((item: any, index: number) => (
<div key={index} data-testid={`virtuoso-item-${index}`}>
{itemContent(index, item)}
</div>
))}
</div>
),
Virtuoso: React.forwardRef((props: any, ref: any) => {
lastVirtuosoProps = props
lastScrollToIndex = vi.fn()
if (ref) {
ref.current = { scrollToIndex: lastScrollToIndex }
}
const { data, itemContent } = props
return (
<div data-testid="virtuoso-list" data-item-count={data.length}>
{data.map((item: any, index: number) => (
<div key={index} data-testid={`virtuoso-item-${index}`}>
{itemContent(index, item)}
</div>
))}
</div>
)
}),
}))

describe("MessageList", () => {
Expand Down Expand Up @@ -400,4 +411,67 @@ describe("MessageList", () => {
expect(item2).toHaveTextContent("Second queued")
})
})

describe("auto-scroll behavior", () => {
it("disables followOutput when user scrolls up and re-enables at bottom", () => {
const store = createStore()
store.set(sessionMessagesAtomFamily(sessionId), [
{
ts: 1,
type: "say",
say: "text",
text: "First message",
} as ClineMessage,
])

render(
<Provider store={store}>
<MessageList sessionId={sessionId} />
</Provider>,
)

expect(lastVirtuosoProps.followOutput).toBe("smooth")

act(() => {
lastVirtuosoProps.atBottomStateChange(false)
})

expect(lastVirtuosoProps.followOutput).toBe(false)

act(() => {
lastVirtuosoProps.atBottomStateChange(true)
})

expect(lastVirtuosoProps.followOutput).toBe("smooth")
})

it("shows scroll-to-bottom button when not at bottom and scrolls on click", () => {
const store = createStore()
store.set(sessionMessagesAtomFamily(sessionId), [
{
ts: 1,
type: "say",
say: "text",
text: "First message",
} as ClineMessage,
])

render(
<Provider store={store}>
<MessageList sessionId={sessionId} />
</Provider>,
)

expect(screen.queryByLabelText("scrollToBottom")).not.toBeInTheDocument()

act(() => {
lastVirtuosoProps.atBottomStateChange(false)
})

const scrollButton = screen.getByLabelText("scrollToBottom")
fireEvent.click(scrollButton)

expect(lastScrollToIndex).toHaveBeenCalledWith({ index: 0, behavior: "smooth" })
})
})
})
Loading