Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prettier thinking blocks #1256

Merged
merged 2 commits into from
Feb 28, 2025
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/young-hornets-taste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"roo-cline": patch
---

Prettier thinking blocks
59 changes: 15 additions & 44 deletions webview-ui/src/components/chat/ChatRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { vscode } from "../../utils/vscode"
import CodeAccordian, { removeLeadingNonAlphanumeric } from "../common/CodeAccordian"
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
import MarkdownBlock from "../common/MarkdownBlock"
import ReasoningBlock from "./ReasoningBlock"
import { ReasoningBlock } from "./ReasoningBlock"
import Thumbnails from "../common/Thumbnails"
import McpResourceRow from "../mcp/McpResourceRow"
import McpToolRow from "../mcp/McpToolRow"
Expand All @@ -25,12 +25,12 @@ import { CheckpointSaved } from "./checkpoints/CheckpointSaved"

interface ChatRowProps {
message: ClineMessage
isExpanded: boolean
onToggleExpand: () => void
lastModifiedMessage?: ClineMessage
isExpanded: boolean
isLast: boolean
onHeightChange: (isTaller: boolean) => void
isStreaming: boolean
onToggleExpand: () => void
onHeightChange: (isTaller: boolean) => void
}

interface ChatRowContentProps extends Omit<ChatRowProps, "onHeightChange"> {}
Expand All @@ -43,10 +43,7 @@ const ChatRow = memo(
const prevHeightRef = useRef(0)

const [chatrow, { height }] = useSize(
<div
style={{
padding: "10px 6px 10px 15px",
}}>
<div className="px-[15px] py-[10px] pr-[6px]">
<ChatRowContent {...props} />
</div>,
)
Expand Down Expand Up @@ -75,33 +72,32 @@ export default ChatRow

export const ChatRowContent = ({
message,
isExpanded,
onToggleExpand,
lastModifiedMessage,
isExpanded,
isLast,
isStreaming,
onToggleExpand,
}: ChatRowContentProps) => {
const { mcpServers, alwaysAllowMcp, currentCheckpoint } = useExtensionState()
const [reasoningCollapsed, setReasoningCollapsed] = useState(false)
const [reasoningCollapsed, setReasoningCollapsed] = useState(true)

// Auto-collapse reasoning when new messages arrive
useEffect(() => {
if (!isLast && message.say === "reasoning") {
setReasoningCollapsed(true)
}
}, [isLast, message.say])
const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => {
if (message.text !== null && message.text !== undefined && message.say === "api_req_started") {
const info: ClineApiReqInfo = JSON.parse(message.text)
return [info.cost, info.cancelReason, info.streamingFailedMessage]
}

return [undefined, undefined, undefined]
}, [message.text, message.say])
// when resuming task, last wont be api_req_failed but a resume_task message, so api_req_started will show loading spinner. that's why we just remove the last api_req_started that failed without streaming anything

// When resuming task, last wont be api_req_failed but a resume_task
// message, so api_req_started will show loading spinner. That's why we just
// remove the last api_req_started that failed without streaming anything.
const apiRequestFailedMessage =
isLast && lastModifiedMessage?.ask === "api_req_failed" // if request is retried then the latest message is a api_req_retried
? lastModifiedMessage?.text
: undefined

const isCommandExecuting =
isLast && lastModifiedMessage?.ask === "command" && lastModifiedMessage?.text?.includes(COMMAND_OUTPUT_STRING)

Expand Down Expand Up @@ -428,32 +424,6 @@ export const ChatRowContent = ({
/>
</>
)
// case "inspectSite":
// const isInspecting =
// isLast && lastModifiedMessage?.say === "inspect_site_result" && !lastModifiedMessage?.images
// return (
// <>
// <div style={headerStyle}>
// {isInspecting ? <ProgressIndicator /> : toolIcon("inspect")}
// <span style={{ fontWeight: "bold" }}>
// {message.type === "ask" ? (
// <>Roo wants to inspect this website:</>
// ) : (
// <>Roo is inspecting this website:</>
// )}
// </span>
// </div>
// <div
// style={{
// borderRadius: 3,
// border: "1px solid var(--vscode-editorGroup-border)",
// overflow: "hidden",
// backgroundColor: CODE_BLOCK_BG_COLOR,
// }}>
// <CodeBlock source={`${"```"}shell\n${tool.path}\n${"```"}`} forceWrap={true} />
// </div>
// </>
// )
case "switchMode":
return (
<>
Expand Down Expand Up @@ -501,6 +471,7 @@ export const ChatRowContent = ({
return (
<ReasoningBlock
content={message.text || ""}
elapsed={isLast && isStreaming ? Date.now() - message.ts : undefined}
isCollapsed={reasoningCollapsed}
onToggleCollapse={() => setReasoningCollapsed(!reasoningCollapsed)}
/>
Expand Down
117 changes: 72 additions & 45 deletions webview-ui/src/components/chat/ReasoningBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,70 +1,97 @@
import React, { useEffect, useRef } from "react"
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
import { useCallback, useEffect, useRef, useState } from "react"
import { CaretDownIcon, CaretUpIcon, CounterClockwiseClockIcon } from "@radix-ui/react-icons"

import MarkdownBlock from "../common/MarkdownBlock"
import { useMount } from "react-use"

interface ReasoningBlockProps {
content: string
elapsed?: number
isCollapsed?: boolean
onToggleCollapse?: () => void
autoHeight?: boolean
}

const ReasoningBlock: React.FC<ReasoningBlockProps> = ({
content,
isCollapsed = false,
onToggleCollapse,
autoHeight = false,
}) => {
export const ReasoningBlock = ({ content, elapsed, isCollapsed = false, onToggleCollapse }: ReasoningBlockProps) => {
const contentRef = useRef<HTMLDivElement>(null)
const elapsedRef = useRef<number>(0)
const [thought, setThought] = useState<string>()
const [prevThought, setPrevThought] = useState<string>("Thinking")
const [isTransitioning, setIsTransitioning] = useState<boolean>(false)
const cursorRef = useRef<number>(0)
const queueRef = useRef<string[]>([])

// Scroll to bottom when content updates
useEffect(() => {
if (contentRef.current && !isCollapsed) {
contentRef.current.scrollTop = contentRef.current.scrollHeight
}
}, [content, isCollapsed])

useEffect(() => {
if (elapsed) {
elapsedRef.current = elapsed
}
}, [elapsed])

// Process the transition queue.
const processNextTransition = useCallback(() => {
const nextThought = queueRef.current.pop()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The recursive setTimeout in processNextTransition has no cleanup. Consider storing timeout IDs and cleaning up on unmount to avoid leaks.

queueRef.current = []

if (nextThought) {
setIsTransitioning(true)
}

setTimeout(() => {
if (nextThought) {
setPrevThought(nextThought)
setIsTransitioning(false)
}

setTimeout(() => processNextTransition(), 500)
}, 200)
}, [])

useMount(() => {
processNextTransition()
})

useEffect(() => {
if (content.length - cursorRef.current > 160) {
setThought("... " + content.slice(cursorRef.current))
cursorRef.current = content.length
}
}, [content])

useEffect(() => {
if (thought && thought !== prevThought) {
queueRef.current.push(thought)
}
}, [thought, prevThought])

return (
<div
style={{
backgroundColor: CODE_BLOCK_BG_COLOR,
border: "1px solid var(--vscode-editorGroup-border)",
borderRadius: "3px",
overflow: "hidden",
}}>
<div className="bg-vscode-editor-background border border-vscode-border rounded-xs overflow-hidden">
<div
onClick={onToggleCollapse}
style={{
padding: "8px 12px",
cursor: "pointer",
userSelect: "none",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderBottom: isCollapsed ? "none" : "1px solid var(--vscode-editorGroup-border)",
}}>
<span style={{ fontWeight: "bold" }}>Reasoning</span>
<span className={`codicon codicon-chevron-${isCollapsed ? "right" : "down"}`}></span>
className="flex items-center justify-between gap-1 px-3 py-2 cursor-pointer text-muted-foreground"
onClick={onToggleCollapse}>
<div
className={`truncate flex-1 transition-opacity duration-200 ${isTransitioning ? "opacity-0" : "opacity-100"}`}>
{prevThought}
</div>
<div className="flex flex-row items-center gap-1">
{elapsedRef.current > 1000 && (
<>
<CounterClockwiseClockIcon className="scale-80" />
<div>{Math.round(elapsedRef.current / 1000)}s</div>
</>
)}
{isCollapsed ? <CaretDownIcon /> : <CaretUpIcon />}
</div>
</div>
{!isCollapsed && (
<div
ref={contentRef}
style={{
padding: "8px 12px",
maxHeight: autoHeight ? "none" : "160px",
overflowY: "auto",
}}>
<div
style={{
fontSize: "13px",
opacity: 0.9,
}}>
<MarkdownBlock markdown={content} />
</div>
<div ref={contentRef} className="px-3 max-h-[160px] overflow-y-auto">
<MarkdownBlock markdown={content} />
</div>
)}
</div>
)
}

export default ReasoningBlock
2 changes: 2 additions & 0 deletions webview-ui/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@
--color-vscode-editor-foreground: var(--vscode-editor-foreground);
--color-vscode-editor-background: var(--vscode-editor-background);

--color-vscode-editorGroup-border: var(--vscode-editorGroup-border);

--color-vscode-button-foreground: var(--vscode-button-foreground);
--color-vscode-button-background: var(--vscode-button-background);
--color-vscode-button-secondaryForeground: var(--vscode-button-secondaryForeground);
Expand Down
Loading