mirror of
https://github.com/cline/cline.git
synced 2026-09-02 15:52:29 +08:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d7c920f46 | |||
| 213daf89ba | |||
| a80ad01ed8 | |||
| ac62d82e82 | |||
| 1f8e6fec85 | |||
| 55312590bf | |||
| cad613991e | |||
| 7d8e13809b | |||
| adfea3f341 | |||
| 7b7e61d499 | |||
| 2e3d10f29f | |||
| cecc34e7fa | |||
| 34a173292d | |||
| 38cb29bceb | |||
| ce0ebb1a4a | |||
| 2865d2ef66 | |||
| e19ba8cc33 | |||
| 239f63db4e | |||
| ce036701f5 | |||
| 686c86306f | |||
| 980a1fbd2d | |||
| 743191985a |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
enhanced compact task complete ui
|
||||
@@ -0,0 +1,108 @@
|
||||
# Historical Tasks Rendering Bug Fix
|
||||
|
||||
## Problem Description
|
||||
|
||||
Historical (completed) tasks were not displaying their chat history when reopened from the task list. Users would click on a historical task and see an empty chat area with only "Zero-sized element" warnings from react-virtuoso.
|
||||
|
||||
### Symptoms
|
||||
|
||||
1. **New tasks worked fine** - Chat messages displayed normally during task execution
|
||||
2. **Historical tasks failed** - After closing and reopening a task, the chat history was blank
|
||||
3. **React Virtuoso errors** - Console showed repeated "Zero-sized element, this should not happen" warnings
|
||||
4. **No component rendering** - ChatRow components mounted but returned 0 height
|
||||
|
||||
### Root Cause
|
||||
|
||||
The bug was in the `MessageRenderer` component's logic for handling `api_req_started` messages. The code had a "Deterministic flash fix" that would absorb (hide) api_req_started messages that were followed by low-stakes tools, expecting them to be included in a tool group.
|
||||
|
||||
```typescript
|
||||
// BEFORE (Buggy code)
|
||||
if (messageOrGroup.say === "api_req_started" &&
|
||||
isApiReqAbsorbable(messageOrGroup.ts, modifiedMessages)) {
|
||||
return null // Hide the message, expecting tool group to show it
|
||||
}
|
||||
```
|
||||
|
||||
For historical/completed tasks, this created a scenario where:
|
||||
|
||||
1. **api_req_started at end of list** → `isApiReqAbsorbable` returned `true` (it saw low-stakes tools after it)
|
||||
2. **MessageRenderer returned null** → The message was hidden
|
||||
3. **Tool group was never created** → Because the message was at index 6 of 7 messages (near end)
|
||||
4. **Result: Zero-sized element** → React Virtuoso tried to render a div with no content
|
||||
|
||||
### Debug Process
|
||||
|
||||
We added logging at multiple levels to trace the issue:
|
||||
|
||||
1. **ChatRow level** - No logs appeared (component never called)
|
||||
2. **MessageRenderer level** - Logged `[MessageRenderer]` showing api_req_started being processed
|
||||
3. **isApiReqAbsorbable level** - Showed `willAbsorb: true` for historical task messages
|
||||
4. **ToolGroupRenderer level** - Never appeared (tool group not created)
|
||||
|
||||
This confirmed the api_req was being hidden without a replacement, causing the zero-height render.
|
||||
|
||||
## The Fix
|
||||
|
||||
Added a check to prevent absorption of messages near the end of the message list:
|
||||
|
||||
```typescript
|
||||
// AFTER (Fixed code)
|
||||
if (messageOrGroup.say === "api_req_started" &&
|
||||
index < groupedMessages.length - 1 && // NEW: Don't absorb near-end messages
|
||||
isApiReqAbsorbable(messageOrGroup.ts, modifiedMessages)) {
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
### Why This Works
|
||||
|
||||
- **For active tasks**: Messages in the middle of the list that are followed by tools still get absorbed correctly (no UI flash)
|
||||
- **For historical tasks**: The final api_req_started (at or near the end) is NOT absorbed, so it renders normally with its thinking block UI
|
||||
- **For all tasks**: Prevents hiding messages when there's no subsequent content to create a tool group
|
||||
|
||||
## Files Modified
|
||||
|
||||
### MessageRenderer.tsx
|
||||
|
||||
```typescript
|
||||
// webview-ui/src/components/chat/chat-view/components/messages/MessageRenderer.tsx
|
||||
|
||||
// Added index check before absorbing api_req_started
|
||||
if (messageOrGroup.say === "api_req_started" &&
|
||||
index < groupedMessages.length - 1 &&
|
||||
isApiReqAbsorbable(messageOrGroup.ts, modifiedMessages)) {
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
After the fix:
|
||||
|
||||
- ✅ **New tasks continue to work** - Messages display during execution
|
||||
- ✅ **Historical tasks now display** - Chat history shows when reopening tasks
|
||||
- ✅ **No zero-sized element errors** - React Virtuoso renders properly
|
||||
- ✅ **Thinking blocks render** - api_req_started messages show with their UI
|
||||
|
||||
## Related Changes
|
||||
|
||||
As part of fixing this issue, we also:
|
||||
|
||||
1. **Added missing props** to ChatRowProps (mode, reasoningContent, responseStarted, isRequestInProgress)
|
||||
2. **Added thinking block components** (TypewriterText, BlinkingCursor, ThinkingBlock)
|
||||
3. **Merged completion output UI** from task-completed-ui branch
|
||||
4. **Created ExpandHandle component** for consistent expand/collapse UI
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
1. **Absorption logic needs bounds checking** - Don't absorb messages at the end of a list if there's no subsequent content
|
||||
2. **Debug logging is essential** - Multi-level logging helped identify exactly where messages disappeared
|
||||
3. **Tool grouping can cause message loss** - If grouping logic fails to create a group, absorbed messages vanish
|
||||
4. **Historical vs active tasks behave differently** - Logic that works for streaming may fail for completed tasks
|
||||
|
||||
## Commit History
|
||||
|
||||
- `7b7e61d49` - fix: prevent absorption of api_req_started at end of message list
|
||||
- `adfea3f34` - feat: apply stash changes and clean up debug logs
|
||||
- `7d8e13809` - feat: restore PlanCompletionOutput and create ExpandHandle component
|
||||
- `cad613991` - feat: use CopyButton in PlanCompletionOutput
|
||||
@@ -161,9 +161,31 @@ export class ReadFileToolHandler implements IFullyManagedTool {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Execute the actual file read operation
|
||||
// Execute the actual file read operation with retry logic for "File not found" errors
|
||||
const supportsImages = config.api.getModel().info.supportsImages ?? false
|
||||
const fileContent = await extractFileContent(absolutePath, supportsImages)
|
||||
let fileContent
|
||||
|
||||
try {
|
||||
fileContent = await extractFileContent(absolutePath, supportsImages)
|
||||
} catch (error) {
|
||||
// Check if this is a "File not found" error
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
if (errorMessage.includes("File not found")) {
|
||||
// Wait briefly and retry once
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
try {
|
||||
fileContent = await extractFileContent(absolutePath, supportsImages)
|
||||
} catch (retryError) {
|
||||
// Both attempts failed - return error to AI context without showing in UI
|
||||
const retryErrorMessage = retryError instanceof Error ? retryError.message : String(retryError)
|
||||
return formatResponse.toolError(retryErrorMessage)
|
||||
}
|
||||
} else {
|
||||
// Not a "File not found" error - throw to trigger normal error handling
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Track file read operation
|
||||
await config.services.fileContextTracker.trackFileContext(relPath!, "read_tool")
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/* Use theme-aware background and border colors for better contrast in all themes */
|
||||
.completion-output-content pre {
|
||||
background-color: rgba(0, 0, 0, 0.15) !important;
|
||||
border-top: 1px solid var(--vscode-editorWidget-border, #cccccc);
|
||||
border-bottom: 1px solid var(--vscode-editorWidget-border, #cccccc);
|
||||
}
|
||||
|
||||
.completion-output-content code {
|
||||
background-color: rgba(0, 0, 0, 0.15) !important;
|
||||
border-top: 1px solid var(--vscode-editorWidget-border, #cccccc);
|
||||
border-bottom: 1px solid var(--vscode-editorWidget-border, #cccccc);
|
||||
}
|
||||
|
||||
.completion-output-content pre > code {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@ import {
|
||||
ChatLayout,
|
||||
convertHtmlToMarkdown,
|
||||
filterVisibleMessages,
|
||||
groupLowStakesTools,
|
||||
groupMessages,
|
||||
InputSection,
|
||||
MessagesArea,
|
||||
@@ -324,7 +325,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
}, [modifiedMessages, currentFocusChainChecklist])
|
||||
|
||||
const groupedMessages = useMemo(() => {
|
||||
return groupMessages(visibleMessages)
|
||||
return groupLowStakesTools(groupMessages(visibleMessages))
|
||||
}, [visibleMessages])
|
||||
|
||||
// Use scroll behavior hook
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { memo } from "react"
|
||||
|
||||
interface ExpandHandleProps {
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
backgroundColor?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable expand/collapse handle component
|
||||
* Used by CompletionOutput, PlanCompletionOutput, CommandOutput, etc.
|
||||
*/
|
||||
const ExpandHandle = memo(({ isExpanded, onToggle, backgroundColor = "var(--vscode-editorGroup-border)" }: ExpandHandleProps) => {
|
||||
return (
|
||||
<div
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "-8px",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: "1px 14px",
|
||||
cursor: "pointer",
|
||||
backgroundColor,
|
||||
borderRadius: "2px",
|
||||
border: "none",
|
||||
}}>
|
||||
<span
|
||||
className={`codicon codicon-triangle-${isExpanded ? "up" : "down"}`}
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: "#000000",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default ExpandHandle
|
||||
@@ -0,0 +1,132 @@
|
||||
import { memo, useState } from "react"
|
||||
import { CHAT_ROW_EXPANDED_BG_COLOR } from "@/components/common/CodeBlock"
|
||||
import { CopyButton } from "@/components/common/CopyButton"
|
||||
import MarkdownBlock from "@/components/common/MarkdownBlock"
|
||||
import ExpandHandle from "./ExpandHandle"
|
||||
|
||||
const neutralColor = "var(--vscode-descriptionForeground)"
|
||||
|
||||
interface PlanCompletionOutputProps {
|
||||
text: string
|
||||
onCopy?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Styled completion output for Plan Mode responses
|
||||
* Uses grayscale colors to distinguish from Act Mode's green success theme
|
||||
*/
|
||||
const PlanCompletionOutput = memo(({ text, onCopy }: PlanCompletionOutputProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true) // Auto-expand by default
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
const outputLines = text.split("\n")
|
||||
const lineCount = outputLines.length
|
||||
const shouldAutoShow = lineCount <= 5
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${isHovered ? "rgba(var(--vscode-descriptionForeground-rgb, 128, 128, 128), 0.5)" : "rgba(var(--vscode-editorGroup-border-rgb, 128, 128, 128), 0.5)"}`,
|
||||
overflow: "visible",
|
||||
backgroundColor: CHAT_ROW_EXPANDED_BG_COLOR,
|
||||
transition: "border-color 0.2s ease",
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "8px 10px",
|
||||
backgroundColor: CHAT_ROW_EXPANDED_BG_COLOR,
|
||||
borderBottom: "1px solid rgba(var(--vscode-editorGroup-border-rgb, 128, 128, 128), 0.5)",
|
||||
borderTopLeftRadius: "6px",
|
||||
borderTopRightRadius: "6px",
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: neutralColor,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--vscode-foreground)",
|
||||
fontWeight: 500,
|
||||
fontSize: "13px",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
Plan Complete
|
||||
</span>
|
||||
</div>
|
||||
<CopyButton textToCopy={text || ""} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
paddingBottom: lineCount > 5 ? "8px" : "0",
|
||||
overflow: "visible",
|
||||
borderTop: "1px solid rgba(255,255,255,.5)",
|
||||
borderBottomLeftRadius: "6px",
|
||||
borderBottomRightRadius: "6px",
|
||||
backgroundColor: CHAT_ROW_EXPANDED_BG_COLOR,
|
||||
}}>
|
||||
<div
|
||||
className="plan-completion-content"
|
||||
style={{
|
||||
maxHeight: shouldAutoShow ? "none" : isExpanded ? "400px" : "150px",
|
||||
overflowY: shouldAutoShow ? "visible" : "auto",
|
||||
scrollBehavior: "smooth",
|
||||
padding: "16px 12px 12px 12px",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
marginBottom: -15,
|
||||
marginTop: -15,
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<style>
|
||||
{`
|
||||
.plan-completion-content hr {
|
||||
opacity: 0.2;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<MarkdownBlock markdown={text} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Expand/collapse notch - only show if there's more than 5 lines */}
|
||||
{lineCount > 5 && (
|
||||
<ExpandHandle
|
||||
backgroundColor={neutralColor}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={() => setIsExpanded(!isExpanded)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default PlanCompletionOutput
|
||||
@@ -124,6 +124,7 @@ export const MessagesArea: React.FC<MessagesAreaProps> = ({
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
overflowY: "scroll", // always show scrollbar
|
||||
overflowAnchor: "none", // prevent scroll jump when content expands
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,244 @@
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import React from "react"
|
||||
import { ClineMessage, ClineSayTool } from "@shared/ExtensionMessage"
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import React, { memo, useCallback, useMemo, useState } from "react"
|
||||
import BrowserSessionRow from "@/components/chat/BrowserSessionRow"
|
||||
import ChatRow from "@/components/chat/ChatRow"
|
||||
import { cleanPathPrefix } from "@/components/common/CodeAccordian"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FileServiceClient } from "@/services/grpc-client"
|
||||
import { MessageHandlers } from "../../types/chatTypes"
|
||||
import {
|
||||
findReasoningForApiReq,
|
||||
isApiReqAbsorbable,
|
||||
isLowStakesTool,
|
||||
isTextMessagePendingToolCall,
|
||||
isToolGroup,
|
||||
} from "../../utils/messageUtils"
|
||||
|
||||
/**
|
||||
* Get display info for a tool message
|
||||
*/
|
||||
function getToolDisplayInfo(message: ClineMessage): { icon: string; path: string; label: string } | null {
|
||||
if (message.say !== "tool" && message.ask !== "tool") return null
|
||||
try {
|
||||
const tool = JSON.parse(message.text || "{}") as ClineSayTool
|
||||
switch (tool.tool) {
|
||||
case "readFile":
|
||||
return { icon: "file-code", path: tool.path || "", label: "read" }
|
||||
case "listFilesTopLevel":
|
||||
return { icon: "folder-opened", path: tool.path || "", label: "listed" }
|
||||
case "listFilesRecursive":
|
||||
return { icon: "folder-opened", path: tool.path || "", label: "listed recursively" }
|
||||
case "listCodeDefinitionNames":
|
||||
return { icon: "symbol-class", path: tool.path || "", label: "definitions" }
|
||||
case "searchFiles":
|
||||
return { icon: "search", path: tool.path || "", label: `search: ${tool.regex}` }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary label for a tool group
|
||||
*/
|
||||
function getToolGroupSummary(messages: ClineMessage[]): string {
|
||||
const toolTypes: string[] = []
|
||||
for (const m of messages) {
|
||||
if (!isLowStakesTool(m)) continue
|
||||
try {
|
||||
const tool = JSON.parse(m.text || "{}") as ClineSayTool
|
||||
if (tool?.tool) toolTypes.push(tool.tool)
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
const readCount = toolTypes.filter((t) => t === "readFile").length
|
||||
const listCount = toolTypes.filter((t) => t === "listFilesTopLevel" || t === "listFilesRecursive").length
|
||||
const searchCount = toolTypes.filter((t) => t === "searchFiles").length
|
||||
const defCount = toolTypes.filter((t) => t === "listCodeDefinitionNames").length
|
||||
|
||||
const parts: string[] = []
|
||||
if (readCount > 0) parts.push(`${readCount} file${readCount > 1 ? "s" : ""}`)
|
||||
if (listCount > 0) parts.push(`${listCount} folder${listCount > 1 ? "s" : ""}`)
|
||||
if (searchCount > 0) parts.push(`${searchCount} search${searchCount > 1 ? "es" : ""}`)
|
||||
if (defCount > 0) parts.push(`${defCount} definition${defCount > 1 ? "s" : ""}`)
|
||||
|
||||
if (parts.length === 0) return "Files"
|
||||
return parts.join(", ")
|
||||
}
|
||||
|
||||
interface ToolGroupRendererProps {
|
||||
messages: ClineMessage[]
|
||||
expandedRows: Record<number, boolean>
|
||||
onToggleExpand: (ts: number) => void
|
||||
index: number
|
||||
groupedMessages: (ClineMessage | ClineMessage[])[]
|
||||
allMessages: ClineMessage[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool has expandable content (folders, search results, definitions)
|
||||
*/
|
||||
function hasExpandableContent(tool: ClineSayTool): boolean {
|
||||
return ["listFilesTopLevel", "listFilesRecursive", "listCodeDefinitionNames", "searchFiles"].includes(tool.tool)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a collapsible group of low-stakes tool calls
|
||||
*/
|
||||
const ToolGroupRenderer = memo(
|
||||
({ messages, expandedRows, onToggleExpand, index, groupedMessages, allMessages }: ToolGroupRendererProps) => {
|
||||
const groupTs = messages[0]?.ts || 0
|
||||
const isExpanded = expandedRows[groupTs] ?? true // Default expanded
|
||||
const isLast = index === groupedMessages.length - 1
|
||||
|
||||
// Track which individual tool items are expanded (for folders, search, etc.)
|
||||
const [expandedItems, setExpandedItems] = useState<Record<number, boolean>>({})
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
onToggleExpand(groupTs)
|
||||
}, [onToggleExpand, groupTs])
|
||||
|
||||
const handleOpenFile = useCallback((filePath: string) => {
|
||||
FileServiceClient.openFileRelativePath(StringRequest.create({ value: filePath })).catch((err) =>
|
||||
console.error("Failed to open file:", err),
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleItemToggle = useCallback((ts: number) => {
|
||||
setExpandedItems((prev) => ({ ...prev, [ts]: !prev[ts] }))
|
||||
}, [])
|
||||
|
||||
const summary = useMemo(() => getToolGroupSummary(messages), [messages])
|
||||
|
||||
// Build tool items with associated reasoning (reasoning that comes BEFORE a tool)
|
||||
const toolsWithReasoning = useMemo(() => {
|
||||
const result: { tool: ClineMessage; parsedTool: ClineSayTool; reasoning?: string }[] = []
|
||||
let pendingReasoning: string[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.say === "reasoning" && msg.text) {
|
||||
pendingReasoning.push(msg.text)
|
||||
} else if (isLowStakesTool(msg)) {
|
||||
let parsedTool: ClineSayTool
|
||||
try {
|
||||
parsedTool = JSON.parse(msg.text || "{}") as ClineSayTool
|
||||
} catch {
|
||||
parsedTool = { tool: "" } as unknown as ClineSayTool
|
||||
}
|
||||
|
||||
result.push({
|
||||
tool: msg,
|
||||
parsedTool,
|
||||
reasoning: pendingReasoning.length > 0 ? pendingReasoning.join("\n\n") : undefined,
|
||||
})
|
||||
pendingReasoning = []
|
||||
}
|
||||
}
|
||||
return result
|
||||
}, [messages])
|
||||
|
||||
return (
|
||||
<div className={cn("px-4 py-2", { "pb-4": isLast })} style={{ color: "var(--vscode-descriptionForeground)" }}>
|
||||
{/* Collapsible header */}
|
||||
<div
|
||||
onClick={handleToggle}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
fontSize: "13px",
|
||||
}}>
|
||||
<span
|
||||
className={`codicon codicon-chevron-${isExpanded ? "down" : "right"}`}
|
||||
style={{ fontSize: "12px", opacity: 0.7 }}
|
||||
/>
|
||||
<span style={{ opacity: 0.9, flex: 1 }}>{summary}</span>
|
||||
</div>
|
||||
|
||||
{/* Expanded content - files/folders with reasoning in tooltip */}
|
||||
{isExpanded && (
|
||||
<div style={{ marginLeft: "18px", marginTop: "2px" }}>
|
||||
{toolsWithReasoning.map(({ tool, parsedTool, reasoning }) => {
|
||||
const info = getToolDisplayInfo(tool)
|
||||
if (!info) return null
|
||||
const isExpandable = hasExpandableContent(parsedTool)
|
||||
const isItemExpanded = expandedItems[tool.ts] ?? false
|
||||
const content = parsedTool.content || null
|
||||
|
||||
return (
|
||||
<div key={tool.ts}>
|
||||
<div
|
||||
onClick={() => {
|
||||
if (isExpandable) {
|
||||
handleItemToggle(tool.ts)
|
||||
} else {
|
||||
handleOpenFile(info.path)
|
||||
}
|
||||
}}
|
||||
{...(reasoning ? { title: reasoning } : {})}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = "var(--vscode-textLink-foreground)"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = "var(--vscode-descriptionForeground)"
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "2px 0",
|
||||
fontSize: "12px",
|
||||
cursor: "pointer",
|
||||
fontFamily: "var(--vscode-editor-font-family)",
|
||||
}}>
|
||||
<span
|
||||
className={`codicon codicon-${info.icon}`}
|
||||
style={{ fontSize: "12px", opacity: 0.7 }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
direction: "rtl",
|
||||
textAlign: "left",
|
||||
}}>
|
||||
{cleanPathPrefix(info.path) + "\u200E"}
|
||||
</span>
|
||||
</div>
|
||||
{/* Expanded content for folders/search/definitions - raw text */}
|
||||
{isExpandable && isItemExpanded && content && (
|
||||
<pre
|
||||
style={{
|
||||
marginLeft: "24px",
|
||||
marginTop: "4px",
|
||||
marginBottom: "4px",
|
||||
fontSize: "11px",
|
||||
opacity: 0.8,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}>
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
interface MessageRendererProps {
|
||||
index: number
|
||||
@@ -34,6 +269,40 @@ export const MessageRenderer: React.FC<MessageRendererProps> = ({
|
||||
inputValue,
|
||||
messageHandlers,
|
||||
}) => {
|
||||
const { mode } = useExtensionState()
|
||||
|
||||
// Get reasoning content and response status for api_req_started messages
|
||||
const reasoningData = useMemo(() => {
|
||||
if (!Array.isArray(messageOrGroup) && messageOrGroup.say === "api_req_started") {
|
||||
// Use the same message source-of-truth that `groupedMessages` is derived from.
|
||||
return findReasoningForApiReq(messageOrGroup.ts, modifiedMessages)
|
||||
}
|
||||
return { reasoning: undefined, responseStarted: false }
|
||||
}, [messageOrGroup, modifiedMessages])
|
||||
|
||||
// Check if a text message is waiting for tool call completion
|
||||
const isRequestInProgress = useMemo(() => {
|
||||
if (!Array.isArray(messageOrGroup) && messageOrGroup.say === "text") {
|
||||
// Use modifiedMessages so this stays consistent with the rendered list.
|
||||
return isTextMessagePendingToolCall(messageOrGroup.ts, modifiedMessages)
|
||||
}
|
||||
return false
|
||||
}, [messageOrGroup, modifiedMessages])
|
||||
|
||||
// Tool group (low-stakes tools grouped together)
|
||||
if (isToolGroup(messageOrGroup)) {
|
||||
return (
|
||||
<ToolGroupRenderer
|
||||
allMessages={modifiedMessages}
|
||||
expandedRows={expandedRows}
|
||||
groupedMessages={groupedMessages}
|
||||
index={index}
|
||||
messages={messageOrGroup}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Browser session group
|
||||
if (Array.isArray(messageOrGroup)) {
|
||||
return (
|
||||
@@ -50,6 +319,16 @@ export const MessageRenderer: React.FC<MessageRendererProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
// Deterministic flash fix:
|
||||
// If this api_req_started is meant to be absorbed into a low-stakes tool group,
|
||||
// never render it as a standalone row.
|
||||
// BUT: Only absorb if this isn't the last/only message (to avoid hiding completed task api_reqs)
|
||||
if (messageOrGroup.say === "api_req_started" &&
|
||||
index < groupedMessages.length - 1 &&
|
||||
isApiReqAbsorbable(messageOrGroup.ts, modifiedMessages)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Determine if this is the last message for status display purposes
|
||||
const nextMessage = index < groupedMessages.length - 1 && groupedMessages[index + 1]
|
||||
const isNextCheckpoint = !Array.isArray(nextMessage) && nextMessage && nextMessage?.say === "checkpoint_created"
|
||||
@@ -67,13 +346,17 @@ export const MessageRenderer: React.FC<MessageRendererProps> = ({
|
||||
inputValue={inputValue}
|
||||
isExpanded={expandedRows[messageOrGroup.ts] || false}
|
||||
isLast={isLast}
|
||||
isRequestInProgress={isRequestInProgress}
|
||||
key={messageOrGroup.ts}
|
||||
lastModifiedMessage={modifiedMessages.at(-1)}
|
||||
message={messageOrGroup}
|
||||
mode={mode}
|
||||
onCancelCommand={() => messageHandlers.executeButtonAction("cancel")}
|
||||
onHeightChange={onHeightChange}
|
||||
onSetQuote={onSetQuote}
|
||||
onToggleExpand={onToggleExpand}
|
||||
reasoningContent={reasoningData.reasoning}
|
||||
responseStarted={reasoningData.responseStarted}
|
||||
sendMessageFromChatRow={messageHandlers.handleSendMessage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -241,30 +241,22 @@ export function useScrollBehavior(
|
||||
disableAutoScrollRef.current = true
|
||||
}
|
||||
|
||||
// Only scroll on collapse, never on expand - expanding should stay in place
|
||||
if (isCollapsing && isAtBottom) {
|
||||
const timer = setTimeout(() => {
|
||||
scrollToBottomAuto()
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
} else if (isLast || isSecondToLast) {
|
||||
if (isCollapsing) {
|
||||
if (isSecondToLast && !isLastCollapsedApiReq) {
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
scrollToBottomAuto()
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
} else {
|
||||
const timer = setTimeout(() => {
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: groupedMessages.length - (isLast ? 1 : 2),
|
||||
align: "start",
|
||||
})
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
} else if (isCollapsing && (isLast || isSecondToLast)) {
|
||||
if (isSecondToLast && !isLastCollapsedApiReq) {
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
scrollToBottomAuto()
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
// When expanding, don't scroll - let the element expand in place
|
||||
},
|
||||
[groupedMessages, expandedRows, scrollToBottomAuto, isAtBottom],
|
||||
)
|
||||
|
||||
@@ -4,7 +4,38 @@
|
||||
|
||||
import { combineApiRequests } from "@shared/combineApiRequests"
|
||||
import { combineCommandSequences } from "@shared/combineCommandSequences"
|
||||
import { ClineMessage, ClineSayBrowserAction } from "@shared/ExtensionMessage"
|
||||
import { ClineMessage, ClineSayBrowserAction, ClineSayTool } from "@shared/ExtensionMessage"
|
||||
|
||||
/**
|
||||
* Low-stakes tool types that should be grouped together
|
||||
*/
|
||||
const LOW_STAKES_TOOLS = new Set([
|
||||
"readFile",
|
||||
"listFilesTopLevel",
|
||||
"listFilesRecursive",
|
||||
"listCodeDefinitionNames",
|
||||
"searchFiles",
|
||||
])
|
||||
|
||||
/**
|
||||
* Check if a tool message is a low-stakes tool
|
||||
*/
|
||||
export function isLowStakesTool(message: ClineMessage): boolean {
|
||||
if (message.say !== "tool" && message.ask !== "tool") return false
|
||||
try {
|
||||
const tool = JSON.parse(message.text || "{}") as ClineSayTool
|
||||
return LOW_STAKES_TOOLS.has(tool.tool)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message group is a tool group (array with _isToolGroup marker)
|
||||
*/
|
||||
export function isToolGroup(item: ClineMessage | ClineMessage[]): item is ClineMessage[] & { _isToolGroup: true } {
|
||||
return Array.isArray(item) && (item as any)._isToolGroup === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine API requests and command sequences in messages
|
||||
@@ -36,6 +67,11 @@ export function filterVisibleMessages(messages: ClineMessage[]): ClineMessage[]
|
||||
case "deleted_api_reqs": // aggregated api_req metrics from deleted messages
|
||||
case "task_progress": // task progress messages are displayed in TaskHeader, not in main chat
|
||||
return false
|
||||
// NOTE: reasoning passes through to be included in tool groups
|
||||
case "api_req_started":
|
||||
// Keep api_req_started visible so the Brain "thinking" UI can remain above
|
||||
// subsequent tool/text output (especially for non-exploratory operations).
|
||||
break
|
||||
case "text":
|
||||
// Sometimes cline returns an empty text message, we don't want to render these. (We also use a say text for user messages, so in case they just sent images we still render that)
|
||||
if ((message.text ?? "") === "" && (message.images?.length ?? 0) === 0) {
|
||||
@@ -151,3 +187,385 @@ export function getTaskMessage(messages: ClineMessage[]): ClineMessage | undefin
|
||||
export function shouldShowScrollButton(disableAutoScroll: boolean, isAtBottom: boolean): boolean {
|
||||
return disableAutoScroll && !isAtBottom
|
||||
}
|
||||
|
||||
/**
|
||||
* Find reasoning content associated with an api_req_started message.
|
||||
* Also returns whether response content (non-reasoning) has started.
|
||||
*/
|
||||
export function findReasoningForApiReq(
|
||||
apiReqTs: number,
|
||||
allMessages: ClineMessage[],
|
||||
): { reasoning: string | undefined; responseStarted: boolean } {
|
||||
const apiReqIndex = allMessages.findIndex((m) => m.ts === apiReqTs && m.say === "api_req_started")
|
||||
if (apiReqIndex === -1) return { reasoning: undefined, responseStarted: false }
|
||||
|
||||
// Collect reasoning and check if response content has started
|
||||
const reasoningParts: string[] = []
|
||||
let responseStarted = false
|
||||
|
||||
for (let i = apiReqIndex + 1; i < allMessages.length; i++) {
|
||||
const msg = allMessages[i]
|
||||
// Stop at next api_req_started
|
||||
if (msg.say === "api_req_started") break
|
||||
// Collect reasoning content
|
||||
if (msg.say === "reasoning" && msg.text) {
|
||||
reasoningParts.push(msg.text)
|
||||
}
|
||||
// Check if non-reasoning response content has started (text, tool calls, etc.)
|
||||
if (msg.say === "text" || msg.say === "tool" || msg.ask === "tool" || msg.ask === "command" || msg.say === "command") {
|
||||
responseStarted = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
reasoning: reasoningParts.length > 0 ? reasoningParts.join("\n\n") : undefined,
|
||||
responseStarted,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the API request info for a checkpoint message.
|
||||
* Looks backwards from the checkpoint to find the preceding api_req_started.
|
||||
* Returns cost and request content.
|
||||
*/
|
||||
export function findApiReqInfoForCheckpoint(
|
||||
checkpointTs: number,
|
||||
allMessages: ClineMessage[],
|
||||
): { cost: number | undefined; request: string | undefined } {
|
||||
const checkpointIndex = allMessages.findIndex((m) => m.ts === checkpointTs && m.say === "checkpoint_created")
|
||||
if (checkpointIndex === -1) return { cost: undefined, request: undefined }
|
||||
|
||||
// Look backwards for the most recent api_req_started
|
||||
for (let i = checkpointIndex - 1; i >= 0; i--) {
|
||||
const msg = allMessages[i]
|
||||
if (msg.say === "api_req_started" && msg.text) {
|
||||
try {
|
||||
const info = JSON.parse(msg.text)
|
||||
return {
|
||||
cost: info.cost,
|
||||
request: info.request,
|
||||
}
|
||||
} catch {
|
||||
return { cost: undefined, request: undefined }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { cost: undefined, request: undefined }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a checkpoint at the given index would be displayed (not absorbed into a tool group).
|
||||
* A checkpoint is absorbed if it's PRECEDED by low-stakes tools (meaning we're in a tool group).
|
||||
* A checkpoint is displayed if it's preceded by non-tool content (meaning no active tool group).
|
||||
*/
|
||||
function isDisplayedCheckpoint(checkpointIndex: number, allMessages: ClineMessage[]): boolean {
|
||||
// Look BACKWARDS to see if we're in a tool group
|
||||
// A checkpoint is absorbed if the previous meaningful content was a low-stakes tool
|
||||
for (let i = checkpointIndex - 1; i >= 0; i--) {
|
||||
const msg = allMessages[i]
|
||||
|
||||
// Skip api_req messages - they don't affect tool group status
|
||||
if (msg.say === "api_req_started" || msg.say === "api_req_finished") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip reasoning messages
|
||||
if (msg.say === "reasoning") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip other checkpoints - they don't end tool groups
|
||||
if (msg.say === "checkpoint_created") {
|
||||
continue
|
||||
}
|
||||
|
||||
// If preceded by a low-stakes tool, this checkpoint is in the tool group (absorbed)
|
||||
if (msg.say === "tool" || msg.ask === "tool") {
|
||||
try {
|
||||
const tool = JSON.parse(msg.text || "{}") as ClineSayTool
|
||||
if (LOW_STAKES_TOOLS.has(tool.tool)) {
|
||||
return false // absorbed into tool group
|
||||
}
|
||||
} catch {
|
||||
// Can't parse, treat as displayed
|
||||
}
|
||||
}
|
||||
|
||||
// Any other content before this checkpoint ends the tool group, so this is displayed
|
||||
return true
|
||||
}
|
||||
|
||||
// Start of messages - checkpoint is displayed (no preceding tool group)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the total cost for the segment starting at a checkpoint.
|
||||
* Looks FORWARD from the checkpoint to the next DISPLAYED checkpoint (skipping absorbed ones).
|
||||
* Sums all api_req_started costs in between.
|
||||
* Returns undefined if the segment is incomplete (no next displayed checkpoint yet).
|
||||
*/
|
||||
export function findNextSegmentCost(checkpointTs: number, allMessages: ClineMessage[]): number | undefined {
|
||||
const checkpointIndex = allMessages.findIndex((m) => m.ts === checkpointTs && m.say === "checkpoint_created")
|
||||
if (checkpointIndex === -1) return undefined
|
||||
|
||||
// Find the next DISPLAYED checkpoint (skip absorbed ones)
|
||||
let nextDisplayedCheckpointIndex = -1
|
||||
for (let i = checkpointIndex + 1; i < allMessages.length; i++) {
|
||||
if (allMessages[i].say === "checkpoint_created") {
|
||||
if (isDisplayedCheckpoint(i, allMessages)) {
|
||||
nextDisplayedCheckpointIndex = i
|
||||
break
|
||||
}
|
||||
// Otherwise continue looking for next displayed checkpoint
|
||||
}
|
||||
}
|
||||
|
||||
// If no next displayed checkpoint, sum to end of messages (in-progress segment)
|
||||
const endIndex = nextDisplayedCheckpointIndex === -1 ? allMessages.length : nextDisplayedCheckpointIndex
|
||||
|
||||
// Sum all api_req_started costs between this checkpoint and the end
|
||||
let totalCost = 0
|
||||
for (let i = checkpointIndex + 1; i < endIndex; i++) {
|
||||
const msg = allMessages[i]
|
||||
if (msg.say === "api_req_started" && msg.text) {
|
||||
try {
|
||||
const info = JSON.parse(msg.text)
|
||||
if (typeof info.cost === "number") {
|
||||
totalCost += info.cost
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return totalCost > 0 ? totalCost : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a text message's associated API request is still in progress.
|
||||
* Returns true if there's no cost yet on the parent api_req_started.
|
||||
*/
|
||||
export function isTextMessagePendingToolCall(textTs: number, allMessages: ClineMessage[]): boolean {
|
||||
// Find the api_req_started that precedes this text message
|
||||
const textIndex = allMessages.findIndex((m) => m.ts === textTs)
|
||||
if (textIndex === -1) return false
|
||||
|
||||
// Look backwards for the most recent api_req_started
|
||||
for (let i = textIndex - 1; i >= 0; i--) {
|
||||
const msg = allMessages[i]
|
||||
if (msg.say === "api_req_started" && msg.text) {
|
||||
try {
|
||||
const info = JSON.parse(msg.text)
|
||||
// If no cost, the request is still in progress
|
||||
return info.cost == null
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this api_req_started should be fully absorbed into a low-stakes tool group.
|
||||
*
|
||||
* This scans FORWARD from the api_req_started until the next api_req_started and checks:
|
||||
* - at least one low-stakes tool exists
|
||||
* - no high-stakes tool/command exists
|
||||
*
|
||||
* Note: this operates on a flat `ClineMessage[]` (e.g. `modifiedMessages`) rather than
|
||||
* grouped messages. It is used at render time to avoid transient UI frames where
|
||||
* `api_req_started` briefly appears before grouping absorbs it.
|
||||
*/
|
||||
export function isApiReqAbsorbable(apiReqTs: number, allMessages: ClineMessage[]): boolean {
|
||||
const apiReqIndex = allMessages.findIndex((m) => m.ts === apiReqTs && m.say === "api_req_started")
|
||||
if (apiReqIndex === -1) return false
|
||||
|
||||
let hasLowStakesTool = false
|
||||
for (let i = apiReqIndex + 1; i < allMessages.length; i++) {
|
||||
const msg = allMessages[i]
|
||||
if (msg.say === "api_req_started") break
|
||||
|
||||
// Reasoning and checkpoints do not affect absorbability
|
||||
if (msg.say === "reasoning" || msg.say === "checkpoint_created") continue
|
||||
|
||||
// Text is allowed (we still want to absorb api_req into the tool group)
|
||||
if (msg.say === "text") continue
|
||||
|
||||
// Low-stakes tools mark absorbability
|
||||
if (isLowStakesTool(msg)) {
|
||||
hasLowStakesTool = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Any other tool/command is considered high-stakes; do not absorb
|
||||
if (msg.say === "tool" || msg.ask === "tool" || msg.say === "command" || msg.ask === "command") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return hasLowStakesTool
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an api_req_started at a given index produces low-stakes tools
|
||||
* (regardless of whether it also produces text).
|
||||
* If so, it should be absorbed into the tool group rather than rendered separately.
|
||||
* The key is: no HIGH-stakes tools (write, edit, command, etc.)
|
||||
*/
|
||||
function isApiReqFollowedOnlyByLowStakesTools(index: number, messages: (ClineMessage | ClineMessage[])[]): boolean {
|
||||
let hasLowStakesTool = false
|
||||
for (let i = index + 1; i < messages.length; i++) {
|
||||
const item = messages[i]
|
||||
if (Array.isArray(item)) {
|
||||
// Browser session - this ends the low-stakes run
|
||||
break
|
||||
}
|
||||
const msg = item
|
||||
// Another api_req_started - stop checking
|
||||
if (msg.say === "api_req_started") break
|
||||
// Reasoning is allowed
|
||||
if (msg.say === "reasoning") continue
|
||||
// Low-stakes tool - mark it
|
||||
if (isLowStakesTool(msg)) {
|
||||
hasLowStakesTool = true
|
||||
continue
|
||||
}
|
||||
// Checkpoint is OK
|
||||
if (msg.say === "checkpoint_created") continue
|
||||
// Text is OK - it will render separately, but we still absorb api_req
|
||||
if (msg.say === "text") continue
|
||||
// High-stakes tool (write, edit, command, etc.) - don't absorb
|
||||
if (msg.say === "tool" || msg.ask === "tool" || msg.ask === "command" || msg.say === "command") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasLowStakesTool
|
||||
}
|
||||
|
||||
/**
|
||||
* Group consecutive low-stakes tools (and their reasoning) into arrays.
|
||||
* Also filters out checkpoints that follow low-stakes tool groups.
|
||||
* Absorbs api_req_started messages that are followed only by low-stakes tools.
|
||||
* Only creates tool groups when there's at least one actual tool - reasoning-only groups are dropped.
|
||||
* Should be called after groupMessages.
|
||||
*/
|
||||
export function groupLowStakesTools(groupedMessages: (ClineMessage | ClineMessage[])[]): (ClineMessage | ClineMessage[])[] {
|
||||
const result: (ClineMessage | ClineMessage[])[] = []
|
||||
let currentToolGroup: ClineMessage[] = []
|
||||
let pendingReasoning: ClineMessage[] = [] // Reasoning waiting for a tool
|
||||
let pendingApiReq: ClineMessage[] = [] // api_req_started waiting to be absorbed or rendered
|
||||
let hasActualTool = false // Track if we have at least one actual tool
|
||||
|
||||
const endToolGroup = () => {
|
||||
if (currentToolGroup.length > 0 && hasActualTool) {
|
||||
// Only create tool group if there's at least one actual tool
|
||||
const toolGroup = [...currentToolGroup] as ClineMessage[] & { _isToolGroup: boolean }
|
||||
toolGroup._isToolGroup = true
|
||||
result.push(toolGroup)
|
||||
// Only clear pending items when we actually used them in a tool group
|
||||
pendingReasoning = []
|
||||
pendingApiReq = []
|
||||
}
|
||||
// Keep pending items if no tool group was created - they may belong to upcoming tools
|
||||
currentToolGroup = []
|
||||
hasActualTool = false
|
||||
}
|
||||
|
||||
groupedMessages.forEach((item, index) => {
|
||||
// If it's already a group (browser session), end current tool group and pass through
|
||||
if (Array.isArray(item)) {
|
||||
endToolGroup()
|
||||
// Render any pending api_req that wasn't absorbed
|
||||
pendingApiReq.forEach((m) => result.push(m))
|
||||
pendingApiReq = []
|
||||
pendingReasoning = []
|
||||
result.push(item)
|
||||
return
|
||||
}
|
||||
|
||||
const message = item
|
||||
|
||||
// Low-stakes tools get grouped
|
||||
if (isLowStakesTool(message)) {
|
||||
// If we have pending api_req, absorb it into the tool group (don't render separately)
|
||||
if (pendingApiReq.length > 0) {
|
||||
currentToolGroup.push(...pendingApiReq)
|
||||
pendingApiReq = []
|
||||
}
|
||||
// If we have pending reasoning, add it (so reasoning comes before its tool)
|
||||
if (pendingReasoning.length > 0) {
|
||||
currentToolGroup.push(...pendingReasoning)
|
||||
pendingReasoning = []
|
||||
}
|
||||
hasActualTool = true
|
||||
currentToolGroup.push(message)
|
||||
}
|
||||
// Reasoning gets collected, will be added when we see a tool
|
||||
else if (message.say === "reasoning") {
|
||||
if (hasActualTool) {
|
||||
// Already in a tool group, add directly
|
||||
currentToolGroup.push(message)
|
||||
} else {
|
||||
// Before first tool, collect as pending
|
||||
pendingReasoning.push(message)
|
||||
}
|
||||
}
|
||||
// api_req_started - check if it should be absorbed or rendered
|
||||
else if (message.say === "api_req_started") {
|
||||
// Check if this api_req is followed only by low-stakes tools
|
||||
if (isApiReqFollowedOnlyByLowStakesTools(index, groupedMessages)) {
|
||||
// Absorb into tool group (don't end current group, keep building)
|
||||
// Any pending api_req from before also gets absorbed
|
||||
if (pendingApiReq.length > 0) {
|
||||
currentToolGroup.push(...pendingApiReq)
|
||||
pendingApiReq = []
|
||||
}
|
||||
if (pendingReasoning.length > 0) {
|
||||
currentToolGroup.push(...pendingReasoning)
|
||||
pendingReasoning = []
|
||||
}
|
||||
// Add this api_req to pending (will be absorbed when we see the tool)
|
||||
pendingApiReq.push(message)
|
||||
} else {
|
||||
// This api_req has non-low-stakes output - end current tool group
|
||||
endToolGroup()
|
||||
// Render any previous pending api_req
|
||||
pendingApiReq.forEach((m) => result.push(m))
|
||||
pendingApiReq = []
|
||||
pendingReasoning = []
|
||||
// Render this api_req normally (will show Thinking block)
|
||||
result.push(message)
|
||||
}
|
||||
}
|
||||
// Checkpoints after tool groups get absorbed (hidden)
|
||||
else if (message.say === "checkpoint_created" && hasActualTool) {
|
||||
// Absorb checkpoint into the tool group only if we have actual tools
|
||||
currentToolGroup.push(message)
|
||||
}
|
||||
// Text messages: render separately but DON'T flush pending api_req
|
||||
// (the api_req may be absorbed by tools that come after the text)
|
||||
else if (message.say === "text") {
|
||||
// Just render text, keep pending api_req for potential upcoming tools
|
||||
result.push(message)
|
||||
}
|
||||
// Everything else ends the tool group and flushes pending
|
||||
else {
|
||||
endToolGroup()
|
||||
// Render any pending api_req that wasn't absorbed
|
||||
pendingApiReq.forEach((m) => result.push(m))
|
||||
pendingApiReq = []
|
||||
pendingReasoning = []
|
||||
result.push(message)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle trailing tool group
|
||||
endToolGroup()
|
||||
// Render any trailing pending api_req
|
||||
pendingApiReq.forEach((m) => result.push(m))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { flip, offset, shift, useFloating } from "@floating-ui/react"
|
||||
import { CheckpointRestoreRequest } from "@shared/proto/cline/checkpoints"
|
||||
import { Int64Request } from "@shared/proto/cline/common"
|
||||
import { ClineCheckpointRestore } from "@shared/WebviewMessage"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import styled from "styled-components"
|
||||
@@ -21,6 +20,7 @@ export const CheckmarkControl = ({ messageTs, isCheckpointCheckedOut }: Checkmar
|
||||
const [restoreWorkspaceDisabled, setRestoreWorkspaceDisabled] = useState(false)
|
||||
const [restoreBothDisabled, setRestoreBothDisabled] = useState(false)
|
||||
const [showRestoreConfirm, setShowRestoreConfirm] = useState(false)
|
||||
const [showMoreOptions, setShowMoreOptions] = useState(false)
|
||||
const { onRelinquishControl } = useExtensionState()
|
||||
|
||||
// Debounce
|
||||
@@ -92,6 +92,7 @@ export const CheckmarkControl = ({ messageTs, isCheckpointCheckedOut }: Checkmar
|
||||
setRestoreWorkspaceDisabled(false)
|
||||
setRestoreBothDisabled(false)
|
||||
setShowRestoreConfirm(false)
|
||||
setShowMoreOptions(false)
|
||||
})
|
||||
}, [onRelinquishControl])
|
||||
|
||||
@@ -216,52 +217,56 @@ export const CheckmarkControl = ({ messageTs, isCheckpointCheckedOut }: Checkmar
|
||||
onMouseLeave={handleMouseLeave}
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}>
|
||||
<RestoreOption>
|
||||
<VSCodeButton
|
||||
disabled={restoreWorkspaceDisabled || isCheckpointCheckedOut}
|
||||
onClick={handleRestoreWorkspace}
|
||||
style={{
|
||||
cursor: isCheckpointCheckedOut
|
||||
? "not-allowed"
|
||||
: restoreWorkspaceDisabled
|
||||
? "wait"
|
||||
: "pointer",
|
||||
width: "100%",
|
||||
marginBottom: "10px",
|
||||
}}>
|
||||
Restore Files
|
||||
</VSCodeButton>
|
||||
<p>
|
||||
Restores your project's files back to a snapshot taken at this point (use "Compare" to
|
||||
see what will be reverted)
|
||||
</p>
|
||||
</RestoreOption>
|
||||
<RestoreOption>
|
||||
<VSCodeButton
|
||||
disabled={restoreTaskDisabled}
|
||||
onClick={handleRestoreTask}
|
||||
style={{
|
||||
cursor: restoreTaskDisabled ? "wait" : "pointer",
|
||||
width: "100%",
|
||||
marginBottom: "10px",
|
||||
}}>
|
||||
Restore Task Only
|
||||
</VSCodeButton>
|
||||
<p>Deletes messages after this point (does not affect workspace files)</p>
|
||||
</RestoreOption>
|
||||
<RestoreOption>
|
||||
<VSCodeButton
|
||||
<PrimaryRestoreOption>
|
||||
<PrimaryButton
|
||||
disabled={restoreBothDisabled}
|
||||
onClick={handleRestoreBoth}
|
||||
style={{
|
||||
cursor: restoreBothDisabled ? "wait" : "pointer",
|
||||
width: "100%",
|
||||
marginBottom: "10px",
|
||||
}}>
|
||||
<i className="codicon codicon-debug-restart" style={{ marginRight: "6px" }} />
|
||||
Restore Files & Task
|
||||
</VSCodeButton>
|
||||
<p>Restores your project's files and deletes all messages after this point</p>
|
||||
</RestoreOption>
|
||||
</PrimaryButton>
|
||||
<p>Revert files and clear messages after this point</p>
|
||||
</PrimaryRestoreOption>
|
||||
|
||||
<MoreOptionsToggle onClick={() => setShowMoreOptions(!showMoreOptions)}>
|
||||
More options
|
||||
<i className={`codicon codicon-chevron-${showMoreOptions ? 'up' : 'down'}`} style={{ marginLeft: "4px", fontSize: "10px" }} />
|
||||
</MoreOptionsToggle>
|
||||
|
||||
{showMoreOptions && (
|
||||
<AdditionalOptions>
|
||||
<RestoreOption>
|
||||
<SecondaryButton
|
||||
disabled={restoreWorkspaceDisabled || isCheckpointCheckedOut}
|
||||
onClick={handleRestoreWorkspace}
|
||||
style={{
|
||||
cursor: isCheckpointCheckedOut
|
||||
? "not-allowed"
|
||||
: restoreWorkspaceDisabled
|
||||
? "wait"
|
||||
: "pointer",
|
||||
}}>
|
||||
<i className="codicon codicon-file-symlink-directory" style={{ marginRight: "6px" }} />
|
||||
Restore Files Only
|
||||
</SecondaryButton>
|
||||
<p>Revert files to this checkpoint</p>
|
||||
</RestoreOption>
|
||||
<RestoreOption>
|
||||
<SecondaryButton
|
||||
disabled={restoreTaskDisabled}
|
||||
onClick={handleRestoreTask}
|
||||
style={{
|
||||
cursor: restoreTaskDisabled ? "wait" : "pointer",
|
||||
}}>
|
||||
<i className="codicon codicon-comment-discussion" style={{ marginRight: "6px" }} />
|
||||
Restore Task Only
|
||||
</SecondaryButton>
|
||||
<p>Clear messages after this point</p>
|
||||
</RestoreOption>
|
||||
</AdditionalOptions>
|
||||
)}
|
||||
</RestoreConfirmTooltip>,
|
||||
document.body,
|
||||
)}
|
||||
@@ -394,22 +399,115 @@ const CustomButton = styled.button<{ disabled?: boolean; isActive?: boolean; $is
|
||||
}
|
||||
`
|
||||
|
||||
const RestoreOption = styled.div`
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--vscode-editorGroup-border);
|
||||
}
|
||||
const PrimaryRestoreOption = styled.div`
|
||||
margin-bottom: 12px;
|
||||
|
||||
p {
|
||||
margin: 0 0 2px 0;
|
||||
margin: 8px 0 0 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
}
|
||||
`
|
||||
|
||||
&:last-child p {
|
||||
margin: 0 0 -2px 0;
|
||||
const PrimaryButton = styled.button`
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.1s ease;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--vscode-button-hoverBackground);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`
|
||||
|
||||
const MoreOptionsToggle = styled.button`
|
||||
width: 100%;
|
||||
padding: 2px 0;
|
||||
background: transparent;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
border: none;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
transition: opacity 0.1s ease;
|
||||
opacity: 0.8;
|
||||
margin-bottom: -4px;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
`
|
||||
|
||||
const AdditionalOptions = styled.div`
|
||||
padding-top: 8px;
|
||||
margin-top: 6px;
|
||||
border-top: 1px solid var(--vscode-editorGroup-border);
|
||||
animation: slideDown 0.15s ease-out;
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const RestoreOption = styled.div`
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 8px 0 0 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
}
|
||||
`
|
||||
|
||||
const SecondaryButton = styled.button`
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
border: 1px solid var(--vscode-editorGroup-border);
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.1s ease;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--vscode-button-secondaryHoverBackground);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`
|
||||
|
||||
@@ -417,10 +515,11 @@ const RestoreConfirmTooltip = styled.div`
|
||||
position: fixed;
|
||||
background: ${CODE_BLOCK_BG_COLOR};
|
||||
border: 1px solid var(--vscode-editorGroup-border);
|
||||
padding: 12px;
|
||||
border-radius: 3px;
|
||||
width: min(calc(100vw - 54px), 600px);
|
||||
padding: 14px;
|
||||
border-radius: 5px;
|
||||
width: min(calc(100vw - 54px), 200px);
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
|
||||
// Add invisible padding to create a safe hover zone
|
||||
&::before {
|
||||
@@ -463,9 +562,10 @@ const RestoreConfirmTooltip = styled.div`
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 6px 0;
|
||||
margin: 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ interface WithCopyButtonProps {
|
||||
const StyledButton = styled(VSCodeButton)`
|
||||
z-index: 1;
|
||||
transform: scale(0.9);
|
||||
background-color: none;
|
||||
outline: none;
|
||||
`
|
||||
|
||||
// Unified container component
|
||||
@@ -46,11 +48,11 @@ const ButtonContainer = styled.div<{ $position?: "top-right" | "bottom-right" }>
|
||||
return "top: 5px; right: 5px;"
|
||||
}
|
||||
}}
|
||||
z-index: 1;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
|
||||
${ContentContainer}:hover & {
|
||||
opacity: 0.5;
|
||||
opacity: 1;
|
||||
}
|
||||
`
|
||||
|
||||
@@ -118,7 +120,6 @@ export const WithCopyButton = forwardRef<HTMLDivElement, WithCopyButtonProps>(
|
||||
) => {
|
||||
return (
|
||||
<ContentContainer className={className} onMouseUp={onMouseUp} ref={ref} style={style} {...props}>
|
||||
{children}
|
||||
{(textToCopy || onCopy) && (
|
||||
<ButtonContainer $position={position}>
|
||||
<CopyButton
|
||||
@@ -128,6 +129,7 @@ export const WithCopyButton = forwardRef<HTMLDivElement, WithCopyButtonProps>(
|
||||
/>
|
||||
</ButtonContainer>
|
||||
)}
|
||||
{children}
|
||||
</ContentContainer>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -44,6 +44,7 @@ const ActModeHighlight: React.FC = () => {
|
||||
interface MarkdownBlockProps {
|
||||
markdown?: string
|
||||
compact?: boolean
|
||||
showCursor?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,7 +207,7 @@ const remarkPreventBoldFilenames = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const StyledMarkdown = styled.div<{ compact?: boolean }>`
|
||||
const StyledMarkdown = styled.div<{ compact?: boolean; $showCursor?: boolean }>`
|
||||
pre {
|
||||
background-color: ${CODE_BLOCK_BG_COLOR};
|
||||
border-radius: 3px;
|
||||
@@ -304,6 +305,26 @@ const StyledMarkdown = styled.div<{ compact?: boolean }>`
|
||||
margin: 4px 0; /* or 0 if you want them very tight */
|
||||
}
|
||||
|
||||
/* Blinking cursor at end of content */
|
||||
${(props) =>
|
||||
props.$showCursor &&
|
||||
`
|
||||
& > :last-child::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1em;
|
||||
background-color: currentColor;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
animation: cursorBlink 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes cursorBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
||||
const PreWithCopyButton = ({ children, ...preProps }: React.HTMLAttributes<HTMLPreElement>) => {
|
||||
@@ -364,7 +385,7 @@ const remarkFilePathDetection = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const MarkdownBlock = memo(({ markdown, compact }: MarkdownBlockProps) => {
|
||||
const MarkdownBlock = memo(({ markdown, compact, showCursor }: MarkdownBlockProps) => {
|
||||
const [reactContent, setMarkdown] = useRemark({
|
||||
remarkPlugins: [
|
||||
remarkPreventBoldFilenames,
|
||||
@@ -461,7 +482,7 @@ const MarkdownBlock = memo(({ markdown, compact }: MarkdownBlockProps) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StyledMarkdown className="ph-no-capture" compact={compact}>
|
||||
<StyledMarkdown $showCursor={showCursor} className="ph-no-capture" compact={compact}>
|
||||
{reactContent}
|
||||
</StyledMarkdown>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user