Compare commits

...

3 Commits

Author SHA1 Message Date
Saoud Rizwan dff0f9d151 Create unlucky-dragons-fly.md 2025-04-10 01:06:51 -07:00
Saoud Rizwan c240504e00 revert comment removal 2025-04-10 01:01:54 -07:00
Saoud Rizwan 0a0c52db50 Use line indicators for checkpoint markers 2025-04-10 00:51:35 -07:00
7 changed files with 480 additions and 289 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Redesigned checkpoint UI to declutter chat view by using a subtle indicator line that expands to a full control panel on hover, with a new date indicator for when it was created
+63 -39
View File
@@ -890,6 +890,7 @@ export class Task {
let responseImages: string[] | undefined
if (response === "messageResponse") {
await this.say("user_feedback", text, images)
await this.saveCheckpoint()
responseText = text
responseImages = images
}
@@ -1146,6 +1147,7 @@ export class Task {
if (userFeedback) {
await this.say("user_feedback", userFeedback.text, userFeedback.images)
await this.saveCheckpoint()
return [
true,
formatResponse.toolResult(
@@ -1563,6 +1565,7 @@ export class Task {
if (text || images?.length) {
pushAdditionalToolFeedback(text, images)
await this.say("user_feedback", text, images)
await this.saveCheckpoint()
}
this.didRejectTool = true // Prevent further tool uses in this message
return false
@@ -1571,6 +1574,7 @@ export class Task {
if (text || images?.length) {
pushAdditionalToolFeedback(text, images)
await this.say("user_feedback", text, images)
await this.saveCheckpoint()
}
return true
}
@@ -1644,7 +1648,7 @@ export class Task {
if (!accessAllowed) {
await this.say("clineignore_error", relPath)
pushToolResult(formatResponse.toolError(formatResponse.clineIgnoreError(relPath)))
await this.saveCheckpoint()
break
}
@@ -1700,6 +1704,7 @@ export class Task {
)
await this.diffViewProvider.revertChanges()
await this.diffViewProvider.reset()
await this.saveCheckpoint()
break
}
} else if (content) {
@@ -1762,14 +1767,14 @@ export class Task {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("replace_in_file", "diff"))
await this.diffViewProvider.reset()
await this.saveCheckpoint()
break
}
if (block.name === "write_to_file" && !content) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("write_to_file", "content"))
await this.diffViewProvider.reset()
await this.saveCheckpoint()
break
}
@@ -1828,6 +1833,7 @@ export class Task {
if (text || images?.length) {
pushAdditionalToolFeedback(text, images)
await this.say("user_feedback", text, images)
await this.saveCheckpoint()
}
this.didRejectTool = true
didApprove = false
@@ -1837,12 +1843,14 @@ export class Task {
if (text || images?.length) {
pushAdditionalToolFeedback(text, images)
await this.say("user_feedback", text, images)
await this.saveCheckpoint()
}
telemetryService.captureToolUsage(this.taskId, block.name, false, true)
}
if (!didApprove) {
await this.diffViewProvider.revertChanges()
await this.saveCheckpoint()
break
}
}
@@ -1903,7 +1911,7 @@ export class Task {
await handleError("writing file", error)
await this.diffViewProvider.revertChanges()
await this.diffViewProvider.reset()
await this.saveCheckpoint()
break
}
}
@@ -1931,7 +1939,7 @@ export class Task {
if (!relPath) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("read_file", "path"))
await this.saveCheckpoint()
break
}
@@ -1939,7 +1947,7 @@ export class Task {
if (!accessAllowed) {
await this.say("clineignore_error", relPath)
pushToolResult(formatResponse.toolError(formatResponse.clineIgnoreError(relPath)))
await this.saveCheckpoint()
break
}
@@ -1961,6 +1969,7 @@ export class Task {
this.removeLastPartialMessageIfExistsWithType("say", "tool")
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
await this.saveCheckpoint()
telemetryService.captureToolUsage(this.taskId, block.name, false, false)
break
}
@@ -1973,12 +1982,12 @@ export class Task {
await this.fileContextTracker.trackFileContext(relPath, "read_tool")
pushToolResult(content)
await this.saveCheckpoint()
break
}
} catch (error) {
await handleError("reading file", error)
await this.saveCheckpoint()
break
}
}
@@ -2008,7 +2017,7 @@ export class Task {
if (!relDirPath) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("list_files", "path"))
await this.saveCheckpoint()
break
}
this.consecutiveMistakeCount = 0
@@ -2039,18 +2048,19 @@ export class Task {
this.removeLastPartialMessageIfExistsWithType("say", "tool")
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
await this.saveCheckpoint()
telemetryService.captureToolUsage(this.taskId, block.name, false, false)
break
}
telemetryService.captureToolUsage(this.taskId, block.name, false, true)
}
pushToolResult(result)
await this.saveCheckpoint()
break
}
} catch (error) {
await handleError("listing files", error)
await this.saveCheckpoint()
break
}
}
@@ -2078,7 +2088,7 @@ export class Task {
if (!relDirPath) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("list_code_definition_names", "path"))
await this.saveCheckpoint()
break
}
@@ -2106,18 +2116,19 @@ export class Task {
this.removeLastPartialMessageIfExistsWithType("say", "tool")
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
await this.saveCheckpoint()
telemetryService.captureToolUsage(this.taskId, block.name, false, false)
break
}
telemetryService.captureToolUsage(this.taskId, block.name, false, true)
}
pushToolResult(result)
await this.saveCheckpoint()
break
}
} catch (error) {
await handleError("parsing source code definitions", error)
await this.saveCheckpoint()
break
}
}
@@ -2149,13 +2160,13 @@ export class Task {
if (!relDirPath) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("search_files", "path"))
await this.saveCheckpoint()
break
}
if (!regex) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("search_files", "regex"))
await this.saveCheckpoint()
break
}
this.consecutiveMistakeCount = 0
@@ -2185,18 +2196,19 @@ export class Task {
this.removeLastPartialMessageIfExistsWithType("say", "tool")
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
await this.saveCheckpoint()
telemetryService.captureToolUsage(this.taskId, block.name, false, false)
break
}
telemetryService.captureToolUsage(this.taskId, block.name, false, true)
}
pushToolResult(results)
await this.saveCheckpoint()
break
}
} catch (error) {
await handleError("searching files", error)
await this.saveCheckpoint()
break
}
}
@@ -2212,6 +2224,7 @@ export class Task {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("browser_action", "action"))
await this.browserSession.closeBrowser()
await this.saveCheckpoint()
}
break
}
@@ -2255,7 +2268,7 @@ export class Task {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("browser_action", "url"))
await this.browserSession.closeBrowser()
await this.saveCheckpoint()
break
}
this.consecutiveMistakeCount = 0
@@ -2271,6 +2284,7 @@ export class Task {
this.removeLastPartialMessageIfExistsWithType("say", "browser_action_launch")
const didApprove = await askApproval("browser_action_launch", url)
if (!didApprove) {
await this.saveCheckpoint()
break
}
}
@@ -2297,7 +2311,7 @@ export class Task {
await this.sayAndCreateMissingParamError("browser_action", "coordinate"),
)
await this.browserSession.closeBrowser()
await this.saveCheckpoint()
break // can't be within an inner switch
}
}
@@ -2306,7 +2320,7 @@ export class Task {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("browser_action", "text"))
await this.browserSession.closeBrowser()
await this.saveCheckpoint()
break
}
}
@@ -2355,7 +2369,7 @@ export class Task {
browserActionResult.screenshot ? [browserActionResult.screenshot] : [],
),
)
await this.saveCheckpoint()
break
case "close":
pushToolResult(
@@ -2363,7 +2377,7 @@ export class Task {
`The browser has been closed. You may now proceed to using other tools.`,
),
)
await this.saveCheckpoint()
break
}
@@ -2372,7 +2386,7 @@ export class Task {
} catch (error) {
await this.browserSession.closeBrowser() // if any error occurs, the browser session is terminated
await handleError("executing browser action", error)
await this.saveCheckpoint()
break
}
}
@@ -2400,7 +2414,7 @@ export class Task {
if (!command) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("execute_command", "command"))
await this.saveCheckpoint()
break
}
if (!requiresApprovalRaw) {
@@ -2408,7 +2422,7 @@ export class Task {
pushToolResult(
await this.sayAndCreateMissingParamError("execute_command", "requires_approval"),
)
await this.saveCheckpoint()
break
}
this.consecutiveMistakeCount = 0
@@ -2456,6 +2470,7 @@ export class Task {
`${this.shouldAutoApproveTool(block.name) && requiresApprovalPerLLM ? COMMAND_REQ_APP_STRING : ""}`, // ugly hack until we refactor combineCommandSequences
)
if (!didApprove) {
await this.saveCheckpoint()
break
}
}
@@ -2491,7 +2506,7 @@ export class Task {
}
} catch (error) {
await handleError("executing command", error)
await this.saveCheckpoint()
break
}
}
@@ -2521,13 +2536,13 @@ export class Task {
if (!server_name) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("use_mcp_tool", "server_name"))
await this.saveCheckpoint()
break
}
if (!tool_name) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("use_mcp_tool", "tool_name"))
await this.saveCheckpoint()
break
}
// arguments are optional, but if they are provided they must be valid JSON
@@ -2551,7 +2566,7 @@ export class Task {
formatResponse.invalidMcpToolArgumentError(server_name, tool_name),
),
)
await this.saveCheckpoint()
break
}
}
@@ -2579,6 +2594,7 @@ export class Task {
this.removeLastPartialMessageIfExistsWithType("say", "use_mcp_server")
const didApprove = await askApproval("use_mcp_server", completeMessage)
if (!didApprove) {
await this.saveCheckpoint()
break
}
}
@@ -2614,7 +2630,7 @@ export class Task {
}
} catch (error) {
await handleError("executing MCP tool", error)
await this.saveCheckpoint()
break
}
}
@@ -2642,13 +2658,13 @@ export class Task {
if (!server_name) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("access_mcp_resource", "server_name"))
await this.saveCheckpoint()
break
}
if (!uri) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("access_mcp_resource", "uri"))
await this.saveCheckpoint()
break
}
this.consecutiveMistakeCount = 0
@@ -2669,6 +2685,7 @@ export class Task {
this.removeLastPartialMessageIfExistsWithType("say", "use_mcp_server")
const didApprove = await askApproval("use_mcp_server", completeMessage)
if (!didApprove) {
await this.saveCheckpoint()
break
}
}
@@ -2688,12 +2705,12 @@ export class Task {
.join("\n\n") || "(Empty response)"
await this.say("mcp_server_response", resourceResultPretty)
pushToolResult(formatResponse.toolResult(resourceResultPretty))
await this.saveCheckpoint()
break
}
} catch (error) {
await handleError("accessing MCP resource", error)
await this.saveCheckpoint()
break
}
}
@@ -2712,7 +2729,7 @@ export class Task {
if (!question) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("ask_followup_question", "question"))
await this.saveCheckpoint()
break
}
this.consecutiveMistakeCount = 0
@@ -2749,12 +2766,12 @@ export class Task {
}
pushToolResult(formatResponse.toolResult(`<answer>\n${text}\n</answer>`, images))
await this.saveCheckpoint()
break
}
} catch (error) {
await handleError("asking question", error)
await this.saveCheckpoint()
break
}
}
@@ -2768,6 +2785,7 @@ export class Task {
if (!context) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("new_task", "context"))
await this.saveCheckpoint()
break
}
this.consecutiveMistakeCount = 0
@@ -2796,10 +2814,12 @@ export class Task {
formatResponse.toolResult(`The user has created a new task with the provided context.`),
)
}
await this.saveCheckpoint()
break
}
} catch (error) {
await handleError("creating new task", error)
await this.saveCheckpoint()
break
}
}
@@ -2860,6 +2880,7 @@ export class Task {
if (text || images?.length) {
telemetryService.captureOptionsIgnored(this.taskId, options.length, "plan")
await this.say("user_feedback", text ?? "", images)
await this.saveCheckpoint()
}
}
@@ -2991,12 +3012,14 @@ export class Task {
// complete command message
const didApprove = await askApproval("command", command)
if (!didApprove) {
await this.saveCheckpoint()
break
}
const [userRejected, execCommandResult] = await this.executeCommandTool(command!)
if (userRejected) {
this.didRejectTool = true
pushToolResult(execCommandResult)
await this.saveCheckpoint()
break
}
// user didn't reject, but the command may have output
@@ -3015,6 +3038,7 @@ export class Task {
break
}
await this.say("user_feedback", text ?? "", images)
await this.saveCheckpoint()
const toolResults: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = []
if (commandResult) {
@@ -3043,7 +3067,7 @@ export class Task {
}
} catch (error) {
await handleError("attempting completion", error)
await this.saveCheckpoint()
break
}
}
@@ -275,19 +275,10 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
consoleLogs: currentPage?.currentState.consoleLogs,
screenshot: currentPage?.currentState.screenshot,
}
const [rowIndex, setRowIndex] = useState<number>(0)
const [hoveredRowIndex, setHoveredRowIndex] = useState<number | null>(null)
const [actionContent, { height: actionHeight }] = useSize(
<div>
{currentPage?.nextAction?.messages.map((message) => (
<BrowserSessionRowContent
key={message.ts}
{...props}
message={message}
setMaxActionHeight={setMaxActionHeight}
rowIndex={rowIndex}
hoveredRowIndex={hoveredRowIndex}
/>
<BrowserSessionRowContent key={message.ts} {...props} message={message} setMaxActionHeight={setMaxActionHeight} />
))}
{!isBrowsing && messages.some((m) => m.say === "browser_action_result") && currentPageIndex === 0 && (
<BrowserActionBox action={"launch"} text={initialUrl} />
@@ -481,8 +472,6 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
interface BrowserSessionRowContentProps extends Omit<BrowserSessionRowProps, "messages"> {
message: ClineMessage
setMaxActionHeight: (height: number) => void
rowIndex: number
hoveredRowIndex: number | null
}
const BrowserSessionRowContent = ({
@@ -492,8 +481,6 @@ const BrowserSessionRowContent = ({
lastModifiedMessage,
isLast,
setMaxActionHeight,
rowIndex,
hoveredRowIndex,
}: BrowserSessionRowContentProps) => {
if (message.ask === "browser_action_launch" || message.say === "browser_action_launch") {
return (
@@ -516,8 +503,6 @@ const BrowserSessionRowContent = ({
return (
<div style={chatRowContentContainerStyle}>
<ChatRowContent
rowIndex={rowIndex}
hoveredRowIndex={hoveredRowIndex}
message={message}
isExpanded={isExpanded(message.ts)}
onToggleExpand={() => {
+30 -30
View File
@@ -48,18 +48,12 @@ interface ChatRowProps {
isExpanded: boolean
onToggleExpand: () => void
lastModifiedMessage?: ClineMessage
isFirst: boolean
isLast: boolean
onHeightChange: (isTaller: boolean) => void
rowIndex: number
hoveredRowIndex: number | null
setHoveredRowIndex: React.Dispatch<React.SetStateAction<number | null>>
}
interface ChatRowContentProps
extends Omit<ChatRowProps, "onHeightChange" | "rowIndex" | "hoveredRowIndex" | "setHoveredRowIndex"> {
rowIndex: number
hoveredRowIndex: number | null
}
interface ChatRowContentProps extends Omit<ChatRowProps, "onHeightChange" | "isFirst"> {}
export const ProgressIndicator = () => (
<div
@@ -92,23 +86,40 @@ const Markdown = memo(({ markdown }: { markdown?: string }) => {
const ChatRow = memo(
(props: ChatRowProps) => {
const { isLast, onHeightChange, message, lastModifiedMessage, rowIndex, hoveredRowIndex, setHoveredRowIndex } = props
const { isLast, isFirst, onHeightChange, message } = props
// Store the previous height to compare with the current height
// This allows us to detect changes without causing re-renders
const prevHeightRef = useRef(0)
// Calculate dynamic styles using the custom hook
const { padding, minHeight } = useChatRowStyles(message, hoveredRowIndex, rowIndex)
const { ...chatRowStyles } = useChatRowStyles(message)
const isCheckpointMessage = message.say === "checkpoint_created"
// Special handling for first row checkpoint
const checkpointStyles = useMemo(() => {
if (isCheckpointMessage) {
// Apply additional styles for first row checkpoints
if (isFirst) {
return {
...chatRowStyles,
marginTop: "3px", // Add a small margin to ensure visibility
}
}
return chatRowStyles
}
return chatRowStyles
}, [chatRowStyles, isCheckpointMessage, isFirst])
// ChatRowContainer with updated styles
const [chatrow, { height }] = useSize(
<ChatRowContainer
style={{ padding, minHeight }}
onMouseEnter={() => setHoveredRowIndex(rowIndex)}
onMouseLeave={() => setHoveredRowIndex(null)}>
<ChatRowContent {...props} rowIndex={rowIndex} hoveredRowIndex={hoveredRowIndex} />
<ChatRowContainer style={checkpointStyles}>
<ChatRowContent {...props} />
</ChatRowContainer>,
)
useEffect(() => {
// Skip height change effects for checkpoint messages
if (isCheckpointMessage) return
// used for partials command output etc.
// NOTE: it's important we don't distinguish between partial or complete here since our scroll effects in chatview need to handle height change during partial -> complete
const isInitialRender = prevHeightRef.current === 0 // prevents scrolling when new element is added since we already scroll for that
@@ -119,7 +130,7 @@ const ChatRow = memo(
}
prevHeightRef.current = height
}
}, [height, isLast, onHeightChange, message])
}, [height, isLast, onHeightChange, message, isCheckpointMessage])
// we cannot return null as virtuoso does not support it so we use a separate visibleMessages array to filter out messages that should not be rendered
return chatrow
@@ -130,15 +141,7 @@ const ChatRow = memo(
export default ChatRow
export const ChatRowContent = ({
message,
isExpanded,
onToggleExpand,
lastModifiedMessage,
isLast,
rowIndex,
hoveredRowIndex,
}: ChatRowContentProps) => {
export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifiedMessage, isLast }: ChatRowContentProps) => {
const { mcpServers, mcpMarketplaceCatalog } = useExtensionState()
const [seeNewChangesDisabled, setSeeNewChangesDisabled] = useState(false)
@@ -988,15 +991,12 @@ export const ChatRowContent = ({
</>
)
case "checkpoint_created":
// Determine if the hover is near the checkpoint marker's visual position (either on the preceding row or the checkpoint row itself)
const isHoveredNearCheckpoint = hoveredRowIndex === rowIndex - 1 || hoveredRowIndex === rowIndex
return (
<>
<CheckmarkControl
messageTs={message.ts}
isCheckpointCheckedOut={message.isCheckpointCheckedOut}
isHoveredNearCheckpoint={isHoveredNearCheckpoint}
isLastRow={isLast}
/>
</>
)
+2 -13
View File
@@ -77,7 +77,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const disableAutoScrollRef = useRef(false)
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const [isAtBottom, setIsAtBottom] = useState(false)
const [hoveredRowIndex, setHoveredRowIndex] = useState<number | null>(null)
// UI layout depends on the last 2 messages
// (since it relies on the content of these messages, we are deep comparing. i.e. the button state after hitting button sets enableButtons to false, and this effect otherwise would have to true again even if messages didn't change
@@ -780,22 +779,12 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
onToggleExpand={() => toggleRowExpansion(messageOrGroup.ts)}
lastModifiedMessage={modifiedMessages.at(-1)}
isLast={index === groupedMessages.length - 1}
isFirst={index === 0}
onHeightChange={handleRowHeightChange}
rowIndex={index}
hoveredRowIndex={hoveredRowIndex}
setHoveredRowIndex={setHoveredRowIndex}
/>
)
},
[
expandedRows,
modifiedMessages,
groupedMessages.length,
toggleRowExpansion,
handleRowHeightChange,
hoveredRowIndex,
setHoveredRowIndex,
],
[expandedRows, modifiedMessages, groupedMessages.length, toggleRowExpansion, handleRowHeightChange],
)
return (
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState, useEffect } from "react"
import { useCallback, useRef, useState, useEffect, useMemo } from "react"
import { useEvent } from "react-use"
import styled from "styled-components"
import { ExtensionMessage } from "@shared/ExtensionMessage"
@@ -11,19 +11,20 @@ import { useFloating, offset, flip, shift } from "@floating-ui/react"
interface CheckmarkControlProps {
messageTs?: number
isCheckpointCheckedOut?: boolean
/** Determines if the hover is near the checkpoint marker's visual position (either on the preceding row or the checkpoint row itself) */
isHoveredNearCheckpoint: boolean
/** Whether this is the last row in the chat */
isLastRow?: boolean
}
export const CheckmarkControl = ({ messageTs, isCheckpointCheckedOut, isHoveredNearCheckpoint }: CheckmarkControlProps) => {
export const CheckmarkControl = ({ messageTs, isCheckpointCheckedOut, isLastRow = false }: CheckmarkControlProps) => {
const [compareDisabled, setCompareDisabled] = useState(false)
const [restoreTaskDisabled, setRestoreTaskDisabled] = useState(false)
const [restoreWorkspaceDisabled, setRestoreWorkspaceDisabled] = useState(false)
const [restoreBothDisabled, setRestoreBothDisabled] = useState(false)
const [showRestoreConfirm, setShowRestoreConfirm] = useState(false)
const [hasMouseEntered, setHasMouseEntered] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const tooltipRef = useRef<HTMLDivElement>(null)
const [isComponentHovered, setIsComponentHovered] = useState(false)
const [isLineHovered, setIsLineHovered] = useState(false)
const { refs, floatingStyles, update, placement } = useFloating({
placement: "bottom-end",
@@ -37,6 +38,65 @@ export const CheckmarkControl = ({ messageTs, isCheckpointCheckedOut, isHoveredN
],
})
// Simple time formatter if date-fns is not available
const getSimpleRelativeTime = (date: Date) => {
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const seconds = Math.floor(diffMs / 1000)
// Handle "just now" case in the fallback too
if (seconds < 180) return "now" // 3 minutes = 180 seconds
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h`
const days = Math.floor(hours / 24)
return `${days}d`
}
// Format the timestamp for relative time display
const relativeTime = useMemo(() => {
if (!messageTs) return ""
// Create a Date object from the timestamp
const date = new Date(messageTs)
const now = new Date()
// Calculate time difference in milliseconds
const diffMs = now.getTime() - date.getTime()
const diffMinutes = Math.floor(diffMs / (1000 * 60))
// Show "just now" if less than 3 minutes
if (diffMinutes < 3) {
return "now"
}
// Fallback formatting if date-fns errors
return getSimpleRelativeTime(date)
}, [messageTs])
// Format the full timestamp for the detailed display (without year)
const formattedTime = useMemo(() => {
if (!messageTs) return ""
const date = new Date(messageTs)
return date.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
})
}, [messageTs])
// Combined time format with relative time and full date
const combinedTimeFormat = useMemo(() => {
if (!relativeTime || !formattedTime) return ""
return `${relativeTime} · ${formattedTime}`
}, [relativeTime, formattedTime])
useEffect(() => {
const handleScroll = () => {
update()
@@ -121,206 +181,334 @@ export const CheckmarkControl = ({ messageTs, isCheckpointCheckedOut, isHoveredN
useEvent("message", handleMessage)
// Hide checkpoint if it is not the currently restored one AND the user is not hovering near it.
// This keeps the UI clean but ensures the checkpoint appear on hover for interaction.
const shouldHideCheckpoint = !isCheckpointCheckedOut && !isHoveredNearCheckpoint
if (shouldHideCheckpoint) {
return null
}
// Modified: Only show the expanded UI on hover (not permanently for checked out checkpoints)
// This way checked out checkpoints only show the line indicator unless hovered
const showExpandedUI =
(isLineHovered || isComponentHovered || showRestoreConfirm) &&
!(showRestoreConfirm === false && isComponentHovered === false && isLineHovered === false)
// The line should still be highlighted when the checkpoint is checked out
const shouldShowHoveredLine = isCheckpointCheckedOut || isLineHovered || isComponentHovered || showRestoreConfirm
return (
<Container isMenuOpen={showRestoreConfirm} $isCheckedOut={isCheckpointCheckedOut} onMouseLeave={handleControlsMouseLeave}>
<i
className="codicon codicon-bookmark"
style={{
color: isCheckpointCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)",
fontSize: "12px",
flexShrink: 0,
<Container isMenuOpen={showRestoreConfirm} $isCheckedOut={isCheckpointCheckedOut}>
{/* Line indicator is still styled differently for checked out checkpoints */}
<CheckpointIndicator
$isCheckedOut={isCheckpointCheckedOut}
$isHovered={shouldShowHoveredLine}
onMouseEnter={() => setIsLineHovered(true)}
onMouseLeave={() => {
setTimeout(() => {
if (!isComponentHovered && !showRestoreConfirm) {
setIsLineHovered(false)
}
}, 50)
}}
/>
<Label $isCheckedOut={isCheckpointCheckedOut}>
{isCheckpointCheckedOut ? "Checkpoint (restored)" : "Checkpoint"}
</Label>
<DottedLine $isCheckedOut={isCheckpointCheckedOut} />
<ButtonGroup>
<CustomButton
<HoverArea
onMouseEnter={() => setIsLineHovered(true)}
onMouseLeave={() => {
if (!isComponentHovered && !showRestoreConfirm) {
setIsLineHovered(false)
}
}}
/>
{showExpandedUI && (
<ExpandedUI
$isCheckedOut={isCheckpointCheckedOut}
disabled={compareDisabled}
style={{ cursor: compareDisabled ? "wait" : "pointer" }}
onClick={() => {
setCompareDisabled(true)
vscode.postMessage({
type: "checkpointDiff",
number: messageTs,
})
$isLastRow={isLastRow}
onMouseEnter={() => {
setIsComponentHovered(true)
setIsLineHovered(true)
}}
onMouseLeave={(e) => {
if (!showRestoreConfirm) {
setIsComponentHovered(false)
setIsLineHovered(false)
} else {
handleControlsMouseLeave(e)
}
}}>
Compare
</CustomButton>
<DottedLine small $isCheckedOut={isCheckpointCheckedOut} />
<div ref={refs.setReference} style={{ position: "relative", marginTop: -2 }}>
<CustomButton
$isCheckedOut={isCheckpointCheckedOut}
isActive={showRestoreConfirm}
onClick={() => setShowRestoreConfirm(true)}>
Restore
</CustomButton>
{showRestoreConfirm &&
createPortal(
<RestoreConfirmTooltip
ref={refs.setFloating}
style={floatingStyles}
data-placement={placement}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}>
<RestoreOption>
<VSCodeButton
onClick={handleRestoreWorkspace}
disabled={restoreWorkspaceDisabled}
style={{
cursor: 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
onClick={handleRestoreTask}
disabled={restoreTaskDisabled}
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
onClick={handleRestoreBoth}
disabled={restoreBothDisabled}
style={{
cursor: restoreBothDisabled ? "wait" : "pointer",
width: "100%",
marginBottom: "10px",
}}>
Restore Files & Task
</VSCodeButton>
<p>Restores your project's files and deletes all messages after this point</p>
</RestoreOption>
</RestoreConfirmTooltip>,
document.body,
)}
</div>
<DottedLine small $isCheckedOut={isCheckpointCheckedOut} />
</ButtonGroup>
<SimpleLayout>
<LabelColumn>
<div style={{ display: "flex", alignItems: "center" }}>
<i
className="codicon codicon-bookmark"
style={{
color: isCheckpointCheckedOut
? "var(--vscode-textLink-foreground)"
: "var(--vscode-descriptionForeground)",
fontSize: "12px",
marginRight: "6px",
}}
/>
<Label $isCheckedOut={isCheckpointCheckedOut}>
{isCheckpointCheckedOut ? "Checkpoint (restored)" : "Checkpoint"}
</Label>
</div>
<TimeLabel $isCheckedOut={isCheckpointCheckedOut}>{combinedTimeFormat}</TimeLabel>
</LabelColumn>
<ButtonsWrapper>
<EnhancedButton
$isCheckedOut={isCheckpointCheckedOut}
disabled={compareDisabled}
style={{ cursor: compareDisabled ? "wait" : "pointer" }}
onClick={() => {
setCompareDisabled(true)
vscode.postMessage({
type: "checkpointDiff",
number: messageTs,
})
}}>
Compare
</EnhancedButton>
<div ref={refs.setReference} style={{ position: "relative" }}>
<EnhancedButton
$isCheckedOut={isCheckpointCheckedOut}
isActive={showRestoreConfirm}
onClick={() => setShowRestoreConfirm(true)}>
Restore
</EnhancedButton>
{showRestoreConfirm &&
createPortal(
<RestoreConfirmTooltip
ref={refs.setFloating}
style={floatingStyles}
data-placement={placement}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}>
<RestoreOption>
<VSCodeButton
onClick={handleRestoreWorkspace}
disabled={restoreWorkspaceDisabled}
style={{
cursor: 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
onClick={handleRestoreTask}
disabled={restoreTaskDisabled}
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
onClick={handleRestoreBoth}
disabled={restoreBothDisabled}
style={{
cursor: restoreBothDisabled ? "wait" : "pointer",
width: "100%",
marginBottom: "10px",
}}>
Restore Files & Task
</VSCodeButton>
<p>Restores your project's files and deletes all messages after this point</p>
</RestoreOption>
</RestoreConfirmTooltip>,
document.body,
)}
</div>
</ButtonsWrapper>
</SimpleLayout>
</ExpandedUI>
)}
</Container>
)
}
const Container = styled.div<{ isMenuOpen?: boolean; $isCheckedOut?: boolean }>`
display: flex;
align-items: center;
padding: 4px 0;
gap: 4px;
position: relative;
min-width: 0;
margin-top: -10px;
margin-bottom: -10px;
opacity: ${(props) => (props.$isCheckedOut ? 1 : props.isMenuOpen ? 1 : 0.5)};
// Updated Container styling - doesn't need to handle as many hover events
const Container = styled.div<{
isMenuOpen?: boolean
$isCheckedOut?: boolean
}>`
position: absolute;
left: 0;
right: 0;
top: 0;
height: 0;
z-index: 10;
pointer-events: auto;
`
&:hover {
opacity: 1;
// Invisible hover area just around the line indicator
const HoverArea = styled.div`
position: absolute;
left: 0;
top: -6px;
width: 20px; /* Wide enough to easily hover */
height: 15px; /* Tall enough to catch hover events */
cursor: pointer;
`
// Make the highlighted line more visible for checked out checkpoints
const CheckpointIndicator = styled.div<{
$isCheckedOut?: boolean
$isHovered?: boolean
}>`
position: absolute;
left: 0;
top: 0;
/* Make checked out checkpoints have a more visible line */
width: ${(props) =>
props.$isCheckedOut ? "10px" /* Wider default for checked out checkpoints */ : props.$isHovered ? "12px" : "8px"};
height: 3px;
background-color: ${(props) =>
props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)"};
opacity: ${(props) => (props.$isCheckedOut ? 1 /* Always full opacity for checked out */ : props.$isHovered ? 1 : 0.6)};
transition:
opacity 0.15s ease-in-out,
width 0.15s ease-in-out;
cursor: pointer;
border-top-right-radius: 2px;
border-bottom-right-radius: 2px;
z-index: 5;
`
// Added label column component for consistent height
const LabelColumn = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
`
// Updated SimpleLayout with center alignment
const SimpleLayout = styled.div`
display: flex;
justify-content: space-between;
align-items: center; /* Center align items vertically */
width: 100%;
`
// Updated ButtonsWrapper with center alignment
const ButtonsWrapper = styled.div`
display: flex;
flex-wrap: wrap;
gap: 6px; /* Reduced gap */
justify-content: flex-end;
align-items: center;
`
// Container for the expanded UI that appears on hover
const ExpandedUI = styled.div<{
$isCheckedOut?: boolean
$isLastRow?: boolean
}>`
position: absolute;
left: 15px;
right: 7px;
${
(props) =>
props.$isLastRow
? "bottom: -3px;" // Position above for last row
: "top: -3px;" // Position below for normal rows
}
background-color: var(--vscode-editor-background);
border-radius: 3px;
padding: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
border: 1px solid var(--vscode-widget-border);
z-index: 20;
animation: ${(props) => (props.$isLastRow ? "fadeInUp" : "fadeIn")} 0.15s ease-in-out;
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`
// Updated Label with slightly smaller font size
const Label = styled.span<{ $isCheckedOut?: boolean }>`
color: ${(props) => (props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)")};
font-size: 9px;
font-size: 11px; // Reduced from 12px
font-weight: 500;
flex-shrink: 0;
`
const DottedLine = styled.div<{ small?: boolean; $isCheckedOut?: boolean }>`
flex: ${(props) => (props.small ? "0 0 5px" : "1")};
min-width: ${(props) => (props.small ? "5px" : "5px")};
height: 1px;
background-image: linear-gradient(
to right,
${(props) => (props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)")} 50%,
transparent 50%
);
background-size: 4px 1px;
background-repeat: repeat-x;
// Updated TimeLabel with word-break enabled
const TimeLabel = styled.span<{ $isCheckedOut?: boolean }>`
font-size: 10px;
color: ${(props) => (props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)")};
margin-top: 3px;
margin-left: 20px;
word-break: break-word; /* Allow breaking words */
white-space: normal; /* Changed from nowrap to allow text to wrap */
max-width: 100%;
display: block; /* Ensure it takes full width for proper breaking */
`
const ButtonGroup = styled.div`
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
`
const CustomButton = styled.button<{ disabled?: boolean; isActive?: boolean; $isCheckedOut?: boolean }>`
// Simplified button styling with minimal padding and no extra styling
const EnhancedButton = styled.button<{
disabled?: boolean
isActive?: boolean
$isCheckedOut?: boolean
}>`
background: ${(props) =>
props.isActive || props.disabled
? props.$isCheckedOut
? "var(--vscode-textLink-foreground)"
: "var(--vscode-descriptionForeground)"
: "transparent"};
border: none;
: "var(--vscode-editor-background)"};
border: 1px solid
${(props) => (props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)")};
color: ${(props) =>
props.isActive || props.disabled
? "var(--vscode-editor-background)"
: props.$isCheckedOut
? "var(--vscode-textLink-foreground)"
: "var(--vscode-descriptionForeground)"};
padding: 2px 6px;
font-size: 9px;
cursor: pointer;
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 1px;
background-image: ${(props) =>
props.isActive || props.disabled
? "none"
: `linear-gradient(to right, ${props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)"} 50%, transparent 50%),
linear-gradient(to bottom, ${props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)"} 50%, transparent 50%),
linear-gradient(to right, ${props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)"} 50%, transparent 50%),
linear-gradient(to bottom, ${props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)"} 50%, transparent 50%)`};
background-size: ${(props) => (props.isActive || props.disabled ? "auto" : `4px 1px, 1px 4px, 4px 1px, 1px 4px`)};
background-repeat: repeat-x, repeat-y, repeat-x, repeat-y;
background-position:
0 0,
100% 0,
0 100%,
0 0;
}
border-radius: 3px;
padding: 3px 4px; /* Minimal padding */
font-size: 10px;
cursor: ${(props) => (props.disabled ? "wait" : "pointer")};
line-height: 1;
height: auto;
margin: 0;
&:hover:not(:disabled) {
background: ${(props) =>
props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)"};
color: var(--vscode-editor-background);
&::before {
display: none;
}
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
opacity: 0.6;
}
`
+26 -26
View File
@@ -1,40 +1,40 @@
import { useMemo } from "react"
import { CSSProperties, useMemo } from "react"
import { ClineMessage } from "@shared/ExtensionMessage"
/**
* Custom hook to determine the dynamic styles for a ChatRowContainer.
*
* This hook calculates the padding and minimum height for a chat row based on
* whether it represents a checkpoint message and its current hover state.
* The goal is to visually collapse checkpoint markers when they are not checked out
* and not being hovered over, while ensuring they remain interactable.
* This hook provides styles for checkpoint messages that control their
* visual appearance based on hover state and whether they're checked out.
*
* @param message - The chat message object for the current row.
* @param hoveredRowIndex - The index of the currently hovered row, or null if none.
* @param rowIndex - The index of the current row being rendered.
* @returns An object containing the calculated style properties (padding and minHeight).
* @returns An object containing the calculated style properties.
*/
export const useChatRowStyles = (
message: ClineMessage,
hoveredRowIndex: number | null,
rowIndex: number,
): { padding: number | undefined; minHeight: number | undefined } => {
export const useChatRowStyles = (message: ClineMessage): CSSProperties => {
return useMemo(() => {
// Check if the current message is a checkpoint creation message.
const isCheckpointMessage = message.say === "checkpoint_created"
// Determine if the hover state is relevant to this row or the one immediately preceding it.
// This is because the checkpoint marker is visually associated with the row *before* the checkpoint message,
// but its visibility is controlled by the hover state of *both* the preceding row and the checkpoint message row itself.
const isHoverRelevant = hoveredRowIndex === rowIndex - 1 || hoveredRowIndex === rowIndex
// For checkpoint messages, make the row take up minimal space
// The checkpoint component will be absolutely positioned
if (isCheckpointMessage) {
return {
padding: 0,
// we can't set height to 0 because virtuoso needs a height to render the row, we can't set to 1 because it results in a visual artifact bug, so we use this hack to make the row almost invisible (and the checkpoint indicator positioned absolutely at this point in the list)
height: 8,
marginTop: -4,
marginBottom: -4,
overflow: "visible", // Allow the absolutely positioned content to be visible
}
}
// Calculate styles based on checkpoint status and hover relevance.
// If it's a checkpoint message, not currently checked out, and not relevantly hovered,
// reset padding to 0 and set minHeight to 1px to visually collapse it.
// Otherwise, use default styles (undefined, letting CSS handle it).
const padding = isCheckpointMessage && !message.isCheckpointCheckedOut && !isHoverRelevant ? 0 : undefined
const minHeight = isCheckpointMessage && !message.isCheckpointCheckedOut && !isHoverRelevant ? 1 : undefined
return { padding, minHeight }
}, [message.say, message.isCheckpointCheckedOut, hoveredRowIndex, rowIndex])
// For non-checkpoint messages, return normal styling
return {
padding: undefined,
minHeight: undefined,
height: undefined,
overflow: undefined,
position: undefined,
}
}, [message.say])
}