This commit is contained in:
Saoud Rizwan
2024-10-05 21:07:39 -04:00
parent 4f51316f76
commit 636e6927f7
3 changed files with 69 additions and 153 deletions

View File

@@ -36,20 +36,12 @@ import { calculateApiCost } from "../utils/cost"
import { fileExistsAtPath } from "../utils/fs" import { fileExistsAtPath } from "../utils/fs"
import { arePathsEqual, getReadablePath } from "../utils/path" import { arePathsEqual, getReadablePath } from "../utils/path"
import { parseMentions } from "./mentions" import { parseMentions } from "./mentions"
import { import { AssistantMessageContent, ToolParamName, ToolUseName } from "./prompts/AssistantMessage"
AssistantMessageContent, import { parseAssistantMessage } from "./prompts/parse-assistant-message"
TextContent,
ToolParamName,
toolParamNames,
ToolUse,
ToolUseName,
toolUseNames,
} from "./prompts/AssistantMessage"
import { formatResponse } from "./prompts/responses" import { formatResponse } from "./prompts/responses"
import { addCustomInstructions, SYSTEM_PROMPT } from "./prompts/system" import { addCustomInstructions, SYSTEM_PROMPT } from "./prompts/system"
import { truncateHalfConversation } from "./sliding-window" import { truncateHalfConversation } from "./sliding-window"
import { ClaudeDevProvider, GlobalFileNames } from "./webview/ClaudeDevProvider" import { ClaudeDevProvider, GlobalFileNames } from "./webview/ClaudeDevProvider"
import { parseAssistantMessage } from "./prompts/parse-assistant-message"
const cwd = const cwd =
vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
@@ -419,7 +411,7 @@ export class ClaudeDev {
if (lastApiReqStartedIndex !== -1) { if (lastApiReqStartedIndex !== -1) {
const lastApiReqStarted = modifiedClaudeMessages[lastApiReqStartedIndex] const lastApiReqStarted = modifiedClaudeMessages[lastApiReqStartedIndex]
const { cost, cancelReason }: ClaudeApiReqInfo = JSON.parse(lastApiReqStarted.text || "{}") const { cost, cancelReason }: ClaudeApiReqInfo = JSON.parse(lastApiReqStarted.text || "{}")
if (cost === undefined || cancelReason === undefined) { if (cost === undefined && cancelReason === undefined) {
modifiedClaudeMessages.splice(lastApiReqStartedIndex, 1) modifiedClaudeMessages.splice(lastApiReqStartedIndex, 1)
} }
} }

View File

@@ -17,7 +17,7 @@ interface ChatRowProps {
onToggleExpand: () => void onToggleExpand: () => void
lastModifiedMessage?: ClaudeMessage lastModifiedMessage?: ClaudeMessage
isLast: boolean isLast: boolean
onHeightChange: (height: number) => void onHeightChange: (isTaller: boolean) => void
} }
interface ChatRowContentProps extends Omit<ChatRowProps, "onHeightChange"> {} interface ChatRowContentProps extends Omit<ChatRowProps, "onHeightChange"> {}
@@ -44,10 +44,10 @@ const ChatRow = memo(
const isInitialRender = prevHeightRef.current === 0 // prevents scrolling when new element is added since we already scroll for that const isInitialRender = prevHeightRef.current === 0 // prevents scrolling when new element is added since we already scroll for that
// height starts off at Infinity // height starts off at Infinity
if (isLast && height !== 0 && height !== Infinity && height !== prevHeightRef.current) { if (isLast && height !== 0 && height !== Infinity && height !== prevHeightRef.current) {
prevHeightRef.current = height
if (!isInitialRender) { if (!isInitialRender) {
onHeightChange(height) onHeightChange(height > prevHeightRef.current)
} }
prevHeightRef.current = height
} }
}, [height, isLast, onHeightChange, message]) }, [height, isLast, onHeightChange, message])

View File

@@ -46,18 +46,13 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const [enableButtons, setEnableButtons] = useState<boolean>(false) const [enableButtons, setEnableButtons] = useState<boolean>(false)
const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>(undefined) const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>(undefined)
const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>(undefined) const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>(undefined)
const [didClickCancel, setDidClickCancel] = useState(false)
const virtuosoRef = useRef<VirtuosoHandle>(null) const virtuosoRef = useRef<VirtuosoHandle>(null)
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({}) const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
const scrollContainerRef = useRef<HTMLDivElement>(null)
const disableAutoScrollRef = useRef(false)
const [showScrollToBottom, setShowScrollToBottom] = useState(false) const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const isAtBottomRef = useRef(false) const [isAtBottom, setIsAtBottom] = useState(false)
const lastScrollTopRef = useRef(0)
const [didClickScrollToBottom, setDidClickScrollToBottom] = useState(false)
const didJustSendMessageRef = useRef(false)
const didScrollUpRef = useRef(false)
const didJustAddMessagesRef = useRef(false)
const [didClickCancel, setDidClickCancel] = useState(false)
// UI layout depends on the last 2 messages // UI layout depends on the last 2 messages
// (since it relies on the content of these messages, we are deep comparing. i.e. the button state after hitting button sets enableButtons to false, and this effect otherwise would have to true again even if messages didn't change // (since it relies on the content of these messages, we are deep comparing. i.e. the button state after hitting button sets enableButtons to false, and this effect otherwise would have to true again even if messages didn't change
@@ -253,12 +248,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setEnableButtons(false) setEnableButtons(false)
// setPrimaryButtonText(undefined) // setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined) // setSecondaryButtonText(undefined)
disableAutoScrollRef.current = false
// when sending a message user should be scrolled to the bottom (this also addresses a bug where sometimes when sending a message virtuoso would jump upwards, possibly due to textarea changing in size)
didJustSendMessageRef.current = true
setTimeout(() => {
didJustSendMessageRef.current = false
}, 400)
} }
}, },
[messages.length, claudeAsk] [messages.length, claudeAsk]
@@ -467,7 +457,12 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
[ts]: !prev[ts], [ts]: !prev[ts],
})) }))
if (isCollapsing && isAtBottomRef.current) { // disable auto scroll when user expands row
if (!isCollapsing) {
disableAutoScrollRef.current = true
}
if (isCollapsing && isAtBottom) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
scrollToBottomAuto() scrollToBottomAuto()
}, 0) }, 0)
@@ -492,72 +487,26 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
} }
} }
}, },
[visibleMessages, expandedRows, scrollToBottomAuto] [visibleMessages, expandedRows, scrollToBottomAuto, isAtBottom]
) )
const handleRowHeightChange = useCallback(() => { const handleRowHeightChange = useCallback(
if ( (isTaller: boolean) => {
isAtBottomRef.current || if (!disableAutoScrollRef.current) {
didClickScrollToBottom || if (isTaller) {
!didScrollUpRef.current ||
didJustSendMessageRef.current
) {
scrollToBottomSmooth() scrollToBottomSmooth()
}
}, [scrollToBottomSmooth, didClickScrollToBottom])
/*
- didScrollUp lets us know if the user scrolled up, so we don't auto scroll down anymore during stream
- this variable is important to make sure we don't show the scroll to bottom button during streaming since isAtBottom gets set to false if the streamed in content is taller than the bottom threshold.
- didClickScrollToBottom is used to keep scrolling down when last row height changes, as isAtBottom may not update fast enough during stream
- we use the following scroll listener to detect that the current scroll point is less than the last scroll point, and in atBottomStateChange we set this back to false if isAtBottom. This way we can know when to show the scroll to bottom button.
- interestingly followoutput would scroll even if the isAtBottom param was false or wrong, so if didScrollUp we don't followoutput to mitigate that issue
*/
const handleScroll = useCallback((e: React.UIEvent<HTMLElement>) => {
if (didJustAddMessagesRef.current) {
// ignore scrolls that occur when new messages are added
return
}
const currentScrollTop = e.currentTarget.scrollTop
if (currentScrollTop < lastScrollTopRef.current) {
didScrollUpRef.current = true
}
lastScrollTopRef.current = currentScrollTop
}, [])
useEffect(() => {
const lastMessage = messages.at(-1)
if (lastMessage) {
switch (lastMessage.say) {
case "api_req_retried": {
// unique case where the api_req_started row will shrink in size when scrollview at bottom, causing didScrollUp to get set to true and followoutput to break. To mitigate this when an api request is retried, it's safe to assume they're already scrolled to the bottom
const timer = setTimeout(() => {
scrollToBottomAuto()
}, 50)
return () => clearTimeout(timer)
}
default:
break
}
}
}, [messages, scrollToBottomAuto, scrollToBottomSmooth])
useEffect(() => {
let shouldScroll = false
if (didJustSendMessageRef.current) {
shouldScroll = true
}
if (isAtBottomRef.current) {
shouldScroll = true
}
if (didScrollUpRef.current) {
// would sometimes get set to true even when new items get added. followoutput taking us to bottom will set this back to false
shouldScroll = false
} else { } else {
shouldScroll = true setTimeout(() => {
scrollToBottomAuto()
}, 0)
} }
}
},
[scrollToBottomSmooth, scrollToBottomAuto]
)
if (shouldScroll) { useEffect(() => {
if (!disableAutoScrollRef.current) {
setTimeout(() => { setTimeout(() => {
scrollToBottomSmooth() scrollToBottomSmooth()
}, 50) }, 50)
@@ -565,13 +514,16 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
} }
}, [visibleMessages.length, scrollToBottomSmooth]) }, [visibleMessages.length, scrollToBottomSmooth])
useEffect(() => { const handleWheel = useCallback((event: Event) => {
didJustAddMessagesRef.current = true const wheelEvent = event as WheelEvent
const timer = setTimeout(() => { if (wheelEvent.deltaY && wheelEvent.deltaY < 0) {
didJustAddMessagesRef.current = false if (scrollContainerRef.current?.contains(wheelEvent.target as Node)) {
}, 100) // user scrolled up
return () => clearTimeout(timer) disableAutoScrollRef.current = true
}, [visibleMessages.length]) }
}
}, [])
useEvent("wheel", handleWheel, window, { passive: true }) // passive improves scrolling performance
const placeholderText = useMemo(() => { const placeholderText = useMemo(() => {
const text = task ? "Type a message (@ to add context)..." : "Type your task here (@ to add context)..." const text = task ? "Type a message (@ to add context)..." : "Type your task here (@ to add context)..."
@@ -644,6 +596,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
)} )}
{task && ( {task && (
<> <>
<div style={{ flexGrow: 1, display: "flex" }} ref={scrollContainerRef}>
<Virtuoso <Virtuoso
ref={virtuosoRef} ref={virtuosoRef}
key={task.ts} // trick to make sure virtuoso re-renders when task changes, and we use initialTopMostItemIndex to start at the bottom key={task.ts} // trick to make sure virtuoso re-renders when task changes, and we use initialTopMostItemIndex to start at the bottom
@@ -652,53 +605,24 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
flexGrow: 1, flexGrow: 1,
overflowY: "scroll", // always show scrollbar overflowY: "scroll", // always show scrollbar
}} }}
// followoutput would not create smooth scroll animation, so we use refs to manually scroll to bottom when needed
// followOutput={(isAtBottom: boolean) => {
// if (didJustSendMessage) {
// return "smooth"
// }
// if (isAtBottom) {
// return "smooth"
// }
// if (didScrollUp) {
// // would sometimes get set to true even when new items get added. followoutput taking us to bottom will set this back to false
// return false
// } else {
// return "smooth"
// }
// // if (isAtBottom) {
// // return "smooth" // can be 'auto' or false to avoid scrolling
// // } else {
// // return false
// // }
// }}
components={{ components={{
Footer: () => <div style={{ height: 5 }} />, // Add empty padding at the bottom Footer: () => <div style={{ height: 5 }} />, // Add empty padding at the bottom
}} }}
// followOutput={(isAtBottom) => {
// const lastMessage = modifiedMessages.at(-1)
// if (lastMessage && shouldShowChatRow(lastMessage)) {
// return "smooth"
// }
// return false
// }}
// increasing top by 3_000 to prevent jumping around when user collapses a row // increasing top by 3_000 to prevent jumping around when user collapses a row
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) 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 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} itemContent={itemContent}
atBottomStateChange={(isAtBottom) => { atBottomStateChange={(isAtBottom) => {
isAtBottomRef.current = isAtBottom setIsAtBottom(isAtBottom)
// setShowScrollToBottom(!value)
if (isAtBottom) { if (isAtBottom) {
didScrollUpRef.current = false disableAutoScrollRef.current = false
setDidClickScrollToBottom(false) // reset for next time user clicks button
} }
setShowScrollToBottom(didScrollUpRef.current && !isAtBottom) setShowScrollToBottom(disableAutoScrollRef.current && !isAtBottom)
}} }}
atBottomThreshold={10} // anything lower causes issues with followOutput atBottomThreshold={10} // anything lower causes issues with followOutput
onScroll={handleScroll}
initialTopMostItemIndex={visibleMessages.length - 1} initialTopMostItemIndex={visibleMessages.length - 1}
/> />
</div>
{showScrollToBottom ? ( {showScrollToBottom ? (
<div <div
style={{ style={{
@@ -708,7 +632,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
<ScrollToBottomButton <ScrollToBottomButton
onClick={() => { onClick={() => {
scrollToBottomSmooth() scrollToBottomSmooth()
setDidClickScrollToBottom(true) disableAutoScrollRef.current = false
}}> }}>
<span className="codicon codicon-chevron-down" style={{ fontSize: "18px" }}></span> <span className="codicon codicon-chevron-down" style={{ fontSize: "18px" }}></span>
</ScrollToBottomButton> </ScrollToBottomButton>
@@ -765,7 +689,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
onSelectImages={selectImages} onSelectImages={selectImages}
shouldDisableImages={shouldDisableImages} shouldDisableImages={shouldDisableImages}
onHeightChange={() => { onHeightChange={() => {
if (isAtBottomRef.current) { if (isAtBottom) {
scrollToBottomAuto() scrollToBottomAuto()
} }
}} }}