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
3 changes: 2 additions & 1 deletion webview-ui/src/components/chat/ChatRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,12 @@ const ChatRow = memo(
)

useEffect(() => {
const isHeightValid = height !== 0 && height !== Infinity
// used for partials, command output, etc.
// NOTE: it's important we don't distinguish between partial or complete here since our scroll effects in chatview need to handle height change during partial -> complete
const isInitialRender = prevHeightRef.current === 0 // prevents scrolling when new element is added since we already scroll for that
// height starts off at Infinity
if (isLast && height !== 0 && height !== Infinity && height !== prevHeightRef.current) {
if (isLast && isHeightValid && height !== prevHeightRef.current) {
if (!isInitialRender) {
onHeightChange(height > prevHeightRef.current)
}
Expand Down
176 changes: 52 additions & 124 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"
import { useDeepCompareEffect, useEvent } from "react-use"
import debounce from "debounce"
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
import removeMd from "remove-markdown"
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
Expand Down Expand Up @@ -48,6 +47,7 @@ import { QueuedMessages } from "./QueuedMessages"
import { WorktreeSelector } from "./WorktreeSelector"
import DismissibleUpsell from "../common/DismissibleUpsell"
import { useCloudUpsell } from "@src/hooks/useCloudUpsell"
import { useScrollLifecycle } from "@src/hooks/useScrollLifecycle"
import { Cloud } from "lucide-react"

export interface ChatViewProps {
Expand All @@ -68,8 +68,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
{ isHidden, showAnnouncement, hideAnnouncement },
ref,
) => {
const isMountedRef = useRef(true)

const [audioBaseUri] = useState(() => {
return (window as unknown as { AUDIO_BASE_URI?: string }).AUDIO_BASE_URI || ""
})
Expand Down Expand Up @@ -156,9 +154,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
const prevExpandedRowsRef = useRef<Record<number, boolean>>()
const scrollContainerRef = useRef<HTMLDivElement>(null)
const stickyFollowRef = useRef<boolean>(false)
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const isAtBottomRef = useRef(false)
const lastTtsRef = useRef<string>("")
const [wasStreaming, setWasStreaming] = useState<boolean>(false)
const [checkpointWarning, setCheckpointWarning] = useState<
Expand Down Expand Up @@ -219,13 +214,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}
}, [isFollowUpAutoApprovalPaused])

useEffect(() => {
isMountedRef.current = true
return () => {
isMountedRef.current = false
}
}, [])

const isProfileDisabled = useMemo(
() => !!apiConfiguration && !ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList),
[apiConfiguration, organizationAllowList],
Expand Down Expand Up @@ -491,38 +479,19 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}
}, [messages.length])

// Reset UI states when task changes. Scroll lifecycle is handled by
// useScrollLifecycle which has its own effect keyed on taskTs.
useEffect(() => {
// Reset UI states only when task changes
setExpandedRows({})
everVisibleMessagesTsRef.current.clear() // Clear for new task
setCurrentFollowUpTs(null) // Clear follow-up answered state for new task
setIsCondensing(false) // Reset condensing state when switching tasks
// Note: sendingDisabled is not reset here as it's managed by message effects
everVisibleMessagesTsRef.current.clear()
setCurrentFollowUpTs(null)
setIsCondensing(false)

// Clear any pending auto-approval timeout from previous task
if (autoApproveTimeoutRef.current) {
clearTimeout(autoApproveTimeoutRef.current)
autoApproveTimeoutRef.current = null
}
// Reset user response flag for new task
userRespondedRef.current = false

// Ensure new task starts anchored to the bottom. Virtuoso's
// initialTopMostItemIndex fires at mount but the message data may
// arrive asynchronously, so we also engage sticky follow and
// explicitly scroll after a frame to handle the race.
let rafId: number | undefined
if (task?.ts) {
stickyFollowRef.current = true
rafId = requestAnimationFrame(() => {
virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "auto" })
})
}
return () => {
if (rafId !== undefined) {
cancelAnimationFrame(rafId)
}
}
}, [task?.ts])

const taskTs = task?.ts
Expand Down Expand Up @@ -550,28 +519,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}
}, [])

useEffect(() => {
const prev = prevExpandedRowsRef.current
let wasAnyRowExpandedByUser = false
if (prev) {
// Check if any row transitioned from false/undefined to true
for (const [tsKey, isExpanded] of Object.entries(expandedRows)) {
const ts = Number(tsKey)
if (isExpanded && !(prev[ts] ?? false)) {
wasAnyRowExpandedByUser = true
break
}
}
}

// Expanding a row indicates the user is browsing; disable sticky follow
if (wasAnyRowExpandedByUser) {
stickyFollowRef.current = false
}

prevExpandedRowsRef.current = expandedRows // Store current state for next comparison
}, [expandedRows])

const isStreaming = useMemo(() => {
// Checking clineAsk isn't enough since messages effect may be called
// again for a tool for example, set clineAsk to its value, and if the
Expand Down Expand Up @@ -1313,28 +1260,49 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
return result
}, [isCondensing, visibleMessages])

// scrolling

const scrollToBottomSmooth = useMemo(
() =>
debounce(() => virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "smooth" }), 10, {
immediate: true,
}),
[],
)
// Scroll lifecycle is managed by a dedicated hook to keep ChatView focused
// on message handling and UI orchestration.
const {
showScrollToBottom,
handleRowHeightChange,
handleScrollToBottomClick,
enterUserBrowsingHistory,
followOutputCallback,
atBottomStateChangeCallback,
scrollToBottomAuto,
isAtBottomRef,
scrollPhaseRef,
} = useScrollLifecycle({
virtuosoRef,
scrollContainerRef,
taskTs: task?.ts,
isStreaming,
isHidden,
hasTask: !!task,
groupedMessagesLength: groupedMessages.length,
})

// Expanding a row indicates the user is browsing; disable sticky follow.
// Placed after the hook call so enterUserBrowsingHistory is defined.
useEffect(() => {
return () => {
scrollToBottomSmooth.clear()
const prev = prevExpandedRowsRef.current
let wasAnyRowExpandedByUser = false
if (prev) {
for (const [tsKey, isExpanded] of Object.entries(expandedRows)) {
const ts = Number(tsKey)
if (isExpanded && !(prev[ts] ?? false)) {
wasAnyRowExpandedByUser = true
break
}
}
}
}, [scrollToBottomSmooth])

const scrollToBottomAuto = useCallback(() => {
virtuosoRef.current?.scrollTo({
top: Number.MAX_SAFE_INTEGER,
behavior: "auto", // Instant causes crash.
})
}, [])
if (wasAnyRowExpandedByUser) {
enterUserBrowsingHistory("row-expansion")
}

prevExpandedRowsRef.current = expandedRows
}, [enterUserBrowsingHistory, expandedRows])

const handleSetExpandedRow = useCallback(
(ts: number, expand?: boolean) => {
Expand All @@ -1356,28 +1324,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
[handleSetExpandedRow],
)

const handleRowHeightChange = useCallback(
(isTaller: boolean) => {
if (isAtBottomRef.current) {
if (isTaller) {
scrollToBottomSmooth()
} else {
setTimeout(() => scrollToBottomAuto(), 0)
}
}
},
[scrollToBottomSmooth, scrollToBottomAuto],
)

// Disable sticky follow when user scrolls up inside the chat container
const handleWheel = useCallback((event: Event) => {
const wheelEvent = event as WheelEvent
if (wheelEvent.deltaY < 0 && scrollContainerRef.current?.contains(wheelEvent.target as Node)) {
stickyFollowRef.current = false
}
}, [])
useEvent("wheel", handleWheel, window, { passive: true })

// Effect to clear checkpoint warning when messages appear or task changes
useEffect(() => {
if (isHidden || !task) {
Expand Down Expand Up @@ -1522,19 +1468,15 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
switchToMode(allModes[previousModeIndex].slug)
}, [mode, customModes, switchToMode])

// Add keyboard event handler
// Mode switching keyboard handler. Scroll-intent keyboard detection
// (PageUp, Home, ArrowUp) is handled by useScrollLifecycle.
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
// Check for Command/Ctrl + Period (with or without Shift)
// Using event.key to respect keyboard layouts (e.g., Dvorak)
if ((event.metaKey || event.ctrlKey) && event.key === ".") {
event.preventDefault() // Prevent default browser behavior

event.preventDefault()
if (event.shiftKey) {
// Shift + Period = Previous mode
switchToPreviousMode()
} else {
// Just Period = Next mode
switchToNextMode()
}
}
Expand Down Expand Up @@ -1687,15 +1629,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
increaseViewportBy={{ top: 3_000, bottom: 1000 }}
data={groupedMessages}
itemContent={itemContent}
followOutput={(isAtBottom: boolean) => isAtBottom || stickyFollowRef.current}
atBottomStateChange={(isAtBottom: boolean) => {
isAtBottomRef.current = isAtBottom
setShowScrollToBottom(!isAtBottom)
// Clear sticky follow when user scrolls away from bottom
if (!isAtBottom) {
stickyFollowRef.current = false
}
}}
followOutput={followOutputCallback}
atBottomStateChange={atBottomStateChangeCallback}
atBottomThreshold={10}
initialTopMostItemIndex={groupedMessages.length - 1}
/>
Expand All @@ -1710,14 +1645,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
<Button
variant="secondary"
className="flex-[2]"
onClick={() => {
// Engage sticky follow until user scrolls up
stickyFollowRef.current = true
// Pin immediately to avoid lag during fast streaming
scrollToBottomAuto()
// Hide button immediately to prevent flash
setShowScrollToBottom(false)
}}>
onClick={handleScrollToBottomClick}>
<span className="codicon codicon-chevron-down"></span>
</Button>
</StandardTooltip>
Expand Down Expand Up @@ -1823,7 +1751,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
onSelectImages={selectImages}
shouldDisableImages={shouldDisableImages}
onHeightChange={() => {
if (isAtBottomRef.current) {
if (isAtBottomRef.current && scrollPhaseRef.current !== "USER_BROWSING_HISTORY") {
scrollToBottomAuto()
}
}}
Expand Down
Loading
Loading