mirror of
https://github.com/pacnpal/Roo-Code.git
synced 2025-12-20 04:11:10 -05:00
Add scrolling animation for streaming
This commit is contained in:
13
webview-ui/package-lock.json
generated
13
webview-ui/package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vscode/webview-ui-toolkit": "^1.4.0",
|
||||
"debounce": "^2.1.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fuse.js": "^7.0.0",
|
||||
"react": "^18.3.1",
|
||||
@@ -7326,6 +7327,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/debounce": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/debounce/-/debounce-2.1.1.tgz",
|
||||
"integrity": "sha512-+xRWxgel9LgTC4PwKlm7TJUK6B6qsEK77NaiNvXmeQ7Y3e6OVVsBC4a9BSptS/mAYceyAz37Oa8JTTuPRft7uQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.3.5",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vscode/webview-ui-toolkit": "^1.4.0",
|
||||
"debounce": "^2.1.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fuse.js": "^7.0.0",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { VSCodeBadge, VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react"
|
||||
import deepEqual from "fast-deep-equal"
|
||||
import React, { memo, useMemo } from "react"
|
||||
import React, { memo, useEffect, useMemo, useRef } from "react"
|
||||
import { ClaudeApiReqInfo, ClaudeMessage, ClaudeSayTool } from "../../../../src/shared/ExtensionMessage"
|
||||
import { COMMAND_OUTPUT_STRING } from "../../../../src/shared/combineCommandSequences"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
@@ -9,6 +9,7 @@ import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
|
||||
import MarkdownBlock from "../common/MarkdownBlock"
|
||||
import Thumbnails from "../common/Thumbnails"
|
||||
import { highlightMentions } from "./TaskHeader"
|
||||
import { useSize } from "react-use"
|
||||
|
||||
interface ChatRowProps {
|
||||
message: ClaudeMessage
|
||||
@@ -16,12 +17,19 @@ interface ChatRowProps {
|
||||
onToggleExpand: () => void
|
||||
lastModifiedMessage?: ClaudeMessage
|
||||
isLast: boolean
|
||||
onHeightChange: (height: number) => void
|
||||
}
|
||||
|
||||
interface ChatRowContentProps extends Omit<ChatRowProps, "onHeightChange"> {}
|
||||
|
||||
const ChatRow = memo(
|
||||
(props: ChatRowProps) => {
|
||||
// we cannot return null as virtuoso does not support it, so we use a separate visibleMessages array to filter out messages that should not be rendered
|
||||
return (
|
||||
const { isLast, onHeightChange, message } = props
|
||||
// Store the previous height to compare with the current height
|
||||
// This allows us to detect changes without causing re-renders
|
||||
const prevHeightRef = useRef(0)
|
||||
|
||||
const [chatrow, { height }] = useSize(
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 6px 10px 15px",
|
||||
@@ -29,6 +37,21 @@ const ChatRow = memo(
|
||||
<ChatRowContent {...props} />
|
||||
</div>
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// used for partials, command output, etc.
|
||||
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) {
|
||||
prevHeightRef.current = height
|
||||
if (!isInitialRender) {
|
||||
onHeightChange(height)
|
||||
}
|
||||
}
|
||||
}, [height, isLast, onHeightChange, message])
|
||||
|
||||
// we cannot return null as virtuoso does not support it, so we use a separate visibleMessages array to filter out messages that should not be rendered
|
||||
return chatrow
|
||||
},
|
||||
// memo does shallow comparison of props, so we need to do deep comparison of arrays/objects whose properties might change
|
||||
deepEqual
|
||||
@@ -36,7 +59,7 @@ const ChatRow = memo(
|
||||
|
||||
export default ChatRow
|
||||
|
||||
const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifiedMessage, isLast }: ChatRowProps) => {
|
||||
const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifiedMessage, isLast }: ChatRowContentProps) => {
|
||||
const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => {
|
||||
if (message.text != null && message.say === "api_req_started") {
|
||||
const info: ClaudeApiReqInfo = JSON.parse(message.text)
|
||||
@@ -94,27 +117,37 @@ const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifiedMessa
|
||||
<span style={{ color: successColor, fontWeight: "bold" }}>Task Completed</span>,
|
||||
]
|
||||
case "api_req_started":
|
||||
const getIconSpan = (iconName: string, color: string) => (
|
||||
<div
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}>
|
||||
<span
|
||||
className={`codicon codicon-${iconName}`}
|
||||
style={{
|
||||
color,
|
||||
fontSize: 16,
|
||||
marginBottom: "-1.5px",
|
||||
}}></span>
|
||||
</div>
|
||||
)
|
||||
return [
|
||||
cost != null ? (
|
||||
apiReqCancelReason != null ? (
|
||||
apiReqCancelReason === "user_cancelled" ? (
|
||||
<span
|
||||
className="codicon codicon-error"
|
||||
style={{ color: cancelledColor, marginBottom: "-1.5px" }}></span>
|
||||
getIconSpan("error", cancelledColor)
|
||||
) : (
|
||||
<span
|
||||
className="codicon codicon-error"
|
||||
style={{ color: errorColor, marginBottom: "-1.5px" }}></span>
|
||||
getIconSpan("error", errorColor)
|
||||
)
|
||||
) : (
|
||||
<span
|
||||
className="codicon codicon-check"
|
||||
style={{ color: successColor, marginBottom: "-1.5px" }}></span>
|
||||
getIconSpan("check", successColor)
|
||||
)
|
||||
) : apiRequestFailedMessage ? (
|
||||
<span
|
||||
className="codicon codicon-error"
|
||||
style={{ color: errorColor, marginBottom: "-1.5px" }}></span>
|
||||
getIconSpan("error", errorColor)
|
||||
) : (
|
||||
<ProgressIndicator />
|
||||
),
|
||||
@@ -401,7 +434,10 @@ const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifiedMessa
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
|
||||
{icon}
|
||||
{title}
|
||||
{cost != null && cost > 0 && <VSCodeBadge>${Number(cost)?.toFixed(4)}</VSCodeBadge>}
|
||||
{/* Need to render this everytime since it affects height of row by 2px */}
|
||||
<VSCodeBadge style={{ opacity: cost != null && cost > 0 ? 1 : 0 }}>
|
||||
${Number(cost || 0)?.toFixed(4)}
|
||||
</VSCodeBadge>
|
||||
</div>
|
||||
<span className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}></span>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import debounce from "debounce"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useEvent, useMount } from "react-use"
|
||||
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
|
||||
@@ -30,7 +31,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
const { version, claudeMessages: messages, taskHistory, apiConfiguration } = useExtensionState()
|
||||
|
||||
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
|
||||
const task = messages.length > 0 ? messages[0] : undefined // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see ClaudeDev.abort)
|
||||
const task = useMemo(() => messages.at(0), [messages]) // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see ClaudeDev.abort)
|
||||
const modifiedMessages = useMemo(() => combineApiRequests(combineCommandSequences(messages.slice(1))), [messages])
|
||||
// has to be after api_req_finished are all reduced into api_req_started messages
|
||||
const apiMetrics = useMemo(() => getApiMetrics(modifiedMessages), [modifiedMessages])
|
||||
@@ -42,13 +43,16 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
|
||||
// we need to hold on to the ask because useEffect > lastMessage will always let us know when an ask comes in and handle it, but by the time handleMessage is called, the last message might not be the ask anymore (it could be a say that followed)
|
||||
const [claudeAsk, setClaudeAsk] = useState<ClaudeAsk | undefined>(undefined)
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
||||
const [enableButtons, setEnableButtons] = useState<boolean>(false)
|
||||
const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>(undefined)
|
||||
const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>(undefined)
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
|
||||
const [isAtBottom, setIsAtBottom] = useState(false)
|
||||
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
||||
const isAtBottomRef = useRef(false)
|
||||
const lastMsgIndexScrolledOn = useRef<number | undefined>(undefined)
|
||||
const taskMsgTsRef = useRef<number | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
// if last message is an ask, show user ask UI
|
||||
@@ -411,6 +415,29 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
|
||||
// scrolling
|
||||
|
||||
const scrollToBottomSmooth = useMemo(
|
||||
() =>
|
||||
debounce(
|
||||
() => {
|
||||
virtuosoRef.current?.scrollTo({
|
||||
top: Number.MAX_SAFE_INTEGER,
|
||||
behavior: "smooth",
|
||||
})
|
||||
},
|
||||
10,
|
||||
{ immediate: true }
|
||||
),
|
||||
[]
|
||||
)
|
||||
|
||||
const scrollToBottomAuto = useCallback(() => {
|
||||
virtuosoRef.current?.scrollTo({
|
||||
top: Number.MAX_SAFE_INTEGER,
|
||||
behavior: "auto", // instant causes crash
|
||||
})
|
||||
}, [])
|
||||
|
||||
// scroll when user toggles certain rows
|
||||
const toggleRowExpansion = useCallback(
|
||||
(ts: number) => {
|
||||
const isCollapsing = expandedRows[ts] ?? false
|
||||
@@ -423,12 +450,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
[ts]: !prev[ts],
|
||||
}))
|
||||
|
||||
if (isCollapsing && isAtBottom) {
|
||||
if (isCollapsing && isAtBottomRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: visibleMessages.length - 1,
|
||||
align: "end",
|
||||
})
|
||||
scrollToBottomAuto()
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
} else if (isLast || isSecondToLast) {
|
||||
@@ -437,10 +461,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: visibleMessages.length - 1,
|
||||
align: "end",
|
||||
})
|
||||
scrollToBottomAuto()
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
} else {
|
||||
@@ -454,58 +475,49 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
}
|
||||
}
|
||||
},
|
||||
[isAtBottom, visibleMessages, expandedRows]
|
||||
[visibleMessages, expandedRows, scrollToBottomAuto]
|
||||
)
|
||||
|
||||
const [lastScrollMessageCount, setLastScrollMessageCount] = useState<number>(0)
|
||||
const [didScrollFromApiReqTs, setDidScrollFromApiReqTs] = useState<number | undefined>(undefined)
|
||||
|
||||
// only scroll to bottom smooth if not partial
|
||||
useEffect(() => {
|
||||
/*
|
||||
chatgpt scroll animation
|
||||
- theres 1 lines worth padding below the text, so when it starts adding text to that next line, it smoothly animates down. theres some debounce, its not exactly when the new line is added.
|
||||
const lastMsgIndex = visibleMessages.length - 1
|
||||
|
||||
|
||||
// since we have scroll to bottom button we should respect if they scroll up,
|
||||
so our scrolling logic will be:
|
||||
- if at bottom, and last chatrow is partial (streaming), then listen to last chatrow height. if it increases, then animate scroll to bottom
|
||||
- if at bottom, then new complete chatrow will cause normal scroll animation (i.e. inspect site screenshot)
|
||||
- so we have to track this height and reset for new chat row
|
||||
- also need to add a bit more padding at the bottom to let the new text have some extra space before we animate to it
|
||||
|
||||
Notes:
|
||||
- show scroll to bottom button even if a little bit scrolled up so user knows that they wont see stream animation if the button shows. the button could act as a lock in to streamed content
|
||||
- dont show scroll to bottom if no overflow
|
||||
*/
|
||||
|
||||
const lastMessage = visibleMessages.at(-1)
|
||||
if (lastMessage?.partial && isAtBottom) {
|
||||
virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "auto" })
|
||||
} else {
|
||||
// dont scroll if we're just updating the api req started informational body
|
||||
|
||||
const isLastApiReqStarted = lastMessage?.say === "api_req_started"
|
||||
if (didScrollFromApiReqTs && isLastApiReqStarted && lastMessage?.ts === didScrollFromApiReqTs) {
|
||||
return
|
||||
const isNewMessagePartial = visibleMessages.at(lastMsgIndex)?.partial === true
|
||||
if (isAtBottomRef.current && lastMsgIndexScrolledOn.current !== lastMsgIndex) {
|
||||
// NOTE: scroll to bottom may not work if you use margin, see virtuoso's troubleshooting
|
||||
// scrollToBottomSmooth()
|
||||
if (isNewMessagePartial) {
|
||||
// needs to be instant so the smooth animation doesnt coincide with the rest of the partial streaming scrolls
|
||||
scrollToBottomAuto()
|
||||
} else {
|
||||
// We use a setTimeout to ensure new content is rendered before scrolling to the bottom. virtuoso's followOutput would scroll to the bottom before the new content could render.
|
||||
setTimeout(() => {
|
||||
scrollToBottomSmooth()
|
||||
}, 100)
|
||||
}
|
||||
// We use a setTimeout to ensure new content is rendered before scrolling to the bottom. virtuoso's followOutput would scroll to the bottom before the new content could render.
|
||||
const timer = setTimeout(() => {
|
||||
// TODO: we can use virtuoso's isAtBottom to prevent scrolling if user is scrolled up, and show a 'scroll to bottom' button for better UX
|
||||
// NOTE: scroll to bottom may not work if you use margin, see virtuoso's troubleshooting
|
||||
virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "smooth" })
|
||||
setDidScrollFromApiReqTs(isLastApiReqStarted ? lastMessage?.ts : undefined) // need to do this in timer since this effect can get called a few times simultaneously
|
||||
}, 50)
|
||||
// interesting bug worth remembering: i would make a timeout here and return cleartimeout, but it kept getting cleared bc visiblemessages kept changing. even though the cleanup was in the conditional, it still gets called wheneve this effect is used
|
||||
// return () => clearTimeout(timer) // don't NEED to clear this and in fact shouldnt in this case
|
||||
lastMsgIndexScrolledOn.current = lastMsgIndex
|
||||
}
|
||||
}, [visibleMessages, scrollToBottomSmooth, scrollToBottomAuto])
|
||||
|
||||
// scroll to bottom if task changes
|
||||
// (this gets called when messages changes, so we use ref to ts to detect new task)
|
||||
useEffect(() => {
|
||||
if (task && task.ts !== taskMsgTsRef.current) {
|
||||
taskMsgTsRef.current = task.ts
|
||||
const timer = setTimeout(() => {
|
||||
scrollToBottomSmooth()
|
||||
}, 50)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [visibleMessages, didScrollFromApiReqTs])
|
||||
}, [task, scrollToBottomSmooth])
|
||||
|
||||
const scrollToBottom = useCallback((smooth: boolean) => {
|
||||
virtuosoRef.current?.scrollTo({
|
||||
top: Number.MAX_SAFE_INTEGER,
|
||||
behavior: smooth ? "smooth" : "auto", // instant causes crash
|
||||
})
|
||||
}, [])
|
||||
const handleRowHeightChange = useCallback(() => {
|
||||
if (isAtBottomRef.current) {
|
||||
scrollToBottomSmooth()
|
||||
}
|
||||
}, [scrollToBottomSmooth])
|
||||
|
||||
const placeholderText = useMemo(() => {
|
||||
const text = task ? "Type a message (@ to add context)..." : "Type your task here (@ to add context)..."
|
||||
@@ -521,20 +533,12 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
onToggleExpand={() => toggleRowExpansion(message.ts)}
|
||||
lastModifiedMessage={modifiedMessages.at(-1)}
|
||||
isLast={index === visibleMessages.length - 1}
|
||||
onHeightChange={handleRowHeightChange}
|
||||
/>
|
||||
),
|
||||
[expandedRows, modifiedMessages, visibleMessages.length, toggleRowExpansion]
|
||||
[expandedRows, modifiedMessages, visibleMessages.length, toggleRowExpansion, handleRowHeightChange]
|
||||
)
|
||||
|
||||
const handleScroll = useCallback<React.UIEventHandler<HTMLDivElement>>((event) => {
|
||||
const scroller = event.currentTarget
|
||||
const scrollTop = scroller.scrollTop
|
||||
const scrollHeight = scroller.scrollHeight
|
||||
const clientHeight = scroller.clientHeight
|
||||
const scrollToBottomThreshold = 600
|
||||
// setShowScrollToBottom(scrollHeight - scrollTop - clientHeight > scrollToBottomThreshold)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -593,6 +597,12 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
flexGrow: 1,
|
||||
overflowY: "scroll", // always show scrollbar
|
||||
}}
|
||||
followOutput={() => {
|
||||
return false
|
||||
}}
|
||||
components={{
|
||||
Footer: () => <div style={{ height: 5 }} />, // Add empty padding at the bottom
|
||||
}}
|
||||
// followOutput={(isAtBottom) => {
|
||||
// const lastMessage = modifiedMessages.at(-1)
|
||||
// if (lastMessage && shouldShowChatRow(lastMessage)) {
|
||||
@@ -604,9 +614,12 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
increaseViewportBy={{ top: 3_000, bottom: Number.MAX_SAFE_INTEGER }} // hack to make sure the last message is always rendered to get truly perfect scroll to bottom animation when new messages are added (Number.MAX_SAFE_INTEGER is safe for arithmetic operations, which is all virtuoso uses this value for in src/sizeRangeSystem.ts)
|
||||
data={visibleMessages} // messages is the raw format returned by extension, modifiedMessages is the manipulated structure that combines certain messages of related type, and visibleMessages is the filtered structure that removes messages that should not be rendered
|
||||
itemContent={itemContent}
|
||||
atBottomStateChange={setIsAtBottom}
|
||||
atBottomStateChange={(value) => {
|
||||
isAtBottomRef.current = value
|
||||
setShowScrollToBottom(!value)
|
||||
}}
|
||||
atBottomThreshold={100}
|
||||
onScroll={handleScroll}
|
||||
// onScroll={handleScroll}
|
||||
/>
|
||||
{showScrollToBottom ? (
|
||||
<div
|
||||
@@ -614,10 +627,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
display: "flex",
|
||||
padding: "10px 15px 0px 15px",
|
||||
}}>
|
||||
<ScrollToBottomButton
|
||||
onClick={() =>
|
||||
virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "smooth" })
|
||||
}>
|
||||
<ScrollToBottomButton onClick={() => scrollToBottomSmooth()}>
|
||||
<span className="codicon codicon-chevron-down" style={{ fontSize: "18px" }}></span>
|
||||
</ScrollToBottomButton>
|
||||
</div>
|
||||
@@ -673,9 +683,8 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
onSelectImages={selectImages}
|
||||
shouldDisableImages={shouldDisableImages}
|
||||
onHeightChange={() => {
|
||||
if (isAtBottom) {
|
||||
//virtuosoRef.current?.scrollToIndex({ index: "LAST", align: "end", behavior: "auto" })
|
||||
virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "auto" })
|
||||
if (isAtBottomRef.current) {
|
||||
scrollToBottomAuto()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user