Compare commits

...

22 Commits

Author SHA1 Message Date
Jose R. Perez 0d7c920f46 fix: update CheckmarkControl usage for new checkpoint UI
- Removed apiReqInfo and segmentCost props (not needed in new UI)
- New checkpoint UI has primary action (Restore Files & Task)
- Collapsible More options for granular restore actions
- Checkpoint CTAs now integrated
2025-12-18 14:26:02 -05:00
Jose R. Perez 213daf89ba feat: merge checkpoint-ctas UI improvements from stash
- New checkpoint restore UI with primary/secondary actions
- Restore Files & Task as primary action
- More options toggle for granular restore (Files Only, Task Only)
- Improved visual hierarchy with styled buttons
- Collapsible additional options
2025-12-18 14:24:22 -05:00
Jose R. Perez a80ad01ed8 style: reduce hr opacity to 0.2 in PlanCompletionOutput
- Changed hr opacity from 0.5 to 0.2 for more subtle appearance
- Applies to horizontal rules in Plan Mode markdown content
2025-12-18 14:09:38 -05:00
Jose R. Perez ac62d82e82 style: set PlanCompletionOutput border opacity to 0.5
- Changed borderBottom to rgba(255, 255, 255, 0.5) for 50% opacity
- Provides subtle visual separation in Plan Complete output
2025-12-18 13:51:13 -05:00
Jose R. Perez 1f8e6fec85 feat: use Cline logo instead of Brain icon for thinking states
- Replaced Brain icon from lucide-react with ClineLogoWhite
- ClineLogoWhite matches the logo used on welcome screen
- Used in thinking block (streaming and collapsed states)
- Consistent branding across the UI
2025-12-18 13:36:43 -05:00
Jose R. Perez 55312590bf docs: add documentation for historical tasks rendering bug fix
- Documented the zero-sized element bug that prevented historical tasks from displaying
- Explained root cause: api_req absorption without tool group creation
- Detailed debug process and fix implementation
- Included code examples and testing results
2025-12-18 13:33:36 -05:00
Jose R. Perez cad613991e feat: use CopyButton in PlanCompletionOutput
- Replaced custom copy button with shared CopyButton component
- Consistent copy UI across all completion outputs
2025-12-18 12:36:52 -05:00
Jose R. Perez 7d8e13809b feat: restore PlanCompletionOutput and create ExpandHandle component
- Created ExpandHandle component for reusable expand/collapse UI
- Restored PlanCompletionOutput.tsx from stash for Plan Mode responses
- PlanCompletionOutput now uses ExpandHandle for consistency
- All completion outputs (Task, Plan, Command) now share same expand handle
2025-12-18 12:31:11 -05:00
Jose R. Perez adfea3f341 feat: apply stash changes and clean up debug logs
- Applied stash@{0} with UI improvements to ChatRow
- Kept Brain icon instead of ClineLogoIcon for thinking block
- Kept absorption fix for historical tasks (index < groupedMessages.length - 1)
- Removed all debug console.log statements
- Deleted PlanCompletionOutput.tsx (no longer needed)
- Merged thinking block UI with completion output UI successfully
2025-12-18 12:14:15 -05:00
Jose R. Perez 7b7e61d499 fix: prevent absorption of api_req_started at end of message list
- Fixed issue where historical tasks had api_req absorbed but no tool group created
- Added check to only absorb if index < groupedMessages.length - 1
- This fixes zero-sized element errors for completed/historical tasks
- Includes debug logging to trace message rendering (to be removed later)
2025-12-18 12:04:11 -05:00
Jose R. Perez 2e3d10f29f debug: add console logging to trace zero-sized element issue 2025-12-18 11:08:25 -05:00
Jose R. Perez cecc34e7fa fix: properly merge pr/7975 thinking block with task-completed-ui
- Added missing props: mode, reasoningContent, responseStarted, isRequestInProgress
- Added thinking block components: TypewriterText, BlinkingCursor, ThinkingBlock
- Updated api_req_started rendering to use thinking block instead of ErrorBlockTitle
- Fixed checkpoint_created to include apiReqInfo and segmentCost
- Updated Markdown component to support showCursor prop
- Added clineMessages to useExtensionState destructuring
- Combined thinking block UI from pr/7975 with completion output UI from task-completed-ui
2025-12-18 09:53:49 -05:00
Jose R. Perez 34a173292d Merge jose/task-completed-ui into pr/7975 2025-12-18 09:37:47 -05:00
nickbaumann98 38cb29bceb feat: chat view UI improvements - thinking block, cost badge, scroll fix
- Add streaming ThinkingBlock with instant collapse transition
- Move chevron next to 'Thinking' text for better UX
- Badge-style cost display on checkpoints (segmentCost)
- Fix scroll position jumping when expanding rows
- Add showCursor prop to MarkdownBlock for streaming
- Add retry logic for 'File not found' errors in ReadFileToolHandler
2025-12-18 09:33:12 -05:00
Jose R. Perez ce0ebb1a4a feat: copy button fix 2025-12-16 14:00:13 -05:00
Jose R. Perez 2865d2ef66 fix: restore api request and thinking 2025-12-16 12:44:26 -05:00
Jose R. Perez e19ba8cc33 fix: made last task completed expanded by default 2025-12-15 09:15:00 -05:00
Jose R. Perez 239f63db4e fix: minor adjustments 2025-12-10 18:02:31 -05:00
Jose R. Perez ce036701f5 Merge branch 'jose/task-completed-ui' of https://github.com/cline/cline into jose/task-completed-ui 2025-12-10 16:03:26 -05:00
Jose R. Perez 686c86306f feature: adjusted embed component styling 2025-12-10 15:59:18 -05:00
Jose R. Perez 980a1fbd2d feat: enhanced task completed response ui 2025-12-10 14:18:19 -05:00
Jose R. Perez 743191985a feat: enhanced task completed response ui 2025-12-09 14:25:10 -05:00
15 changed files with 1838 additions and 318 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
enhanced compact task complete ui
+108
View File
@@ -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
+2 -1
View File
@@ -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>