Skip to content
Merged
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: 51 additions & 125 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,48 @@ 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,
})

// 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 +1323,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 +1467,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,17 +1628,9 @@ 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}
/>
</div>
{areButtonsVisible && (
Expand All @@ -1710,14 +1643,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 +1749,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