mirror of
https://github.com/cline/cline.git
synced 2026-09-13 09:50:12 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
104a566dbf |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Refactor Task Header UI with interactive context window management
|
||||
@@ -238,8 +238,9 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
|
||||
context.globalState.get<GlobalStateAndSettings["mcpMarketplaceCatalog"]>("mcpMarketplaceCatalog")
|
||||
const qwenCodeOauthPath = context.globalState.get<GlobalStateAndSettings["qwenCodeOauthPath"]>("qwenCodeOauthPath")
|
||||
const customPrompt = context.globalState.get<GlobalStateAndSettings["customPrompt"]>("customPrompt")
|
||||
const autoCondenseThreshold =
|
||||
context.globalState.get<GlobalStateAndSettings["autoCondenseThreshold"]>("autoCondenseThreshold") // number from 0 to 1
|
||||
const autoCondenseThreshold = context.globalState.get<GlobalStateAndSettings["autoCondenseThreshold"]>(
|
||||
"autoCondenseThreshold",
|
||||
) as number | undefined // number from 0 to 1
|
||||
// Get mode-related configurations
|
||||
const mode = context.globalState.get<GlobalStateAndSettings["mode"]>("mode")
|
||||
|
||||
|
||||
@@ -28,14 +28,6 @@ const mockVSCodeDarkTheme = {
|
||||
"--vscode-activityWarningBadge-background": "#F9C20B",
|
||||
"--vscode-badge-background": "#007ACC",
|
||||
"--vscode-badge-foreground": "#FFFFFF",
|
||||
"--vscode-charts-green": "#73C991",
|
||||
"--vscode-charts-yellow": "#F9C20B",
|
||||
"--vscode-charts-red": "#F14C4C",
|
||||
"--vscode-menu-background": "#252526",
|
||||
"--vscode-menu-border": "#454545",
|
||||
"--vscode-menu-foreground": "#CCCCCC",
|
||||
"--vscode-menu-selectionBackground": "#062F4A",
|
||||
"--vscode-menu-selectionForeground": "#FFFFFF",
|
||||
}
|
||||
|
||||
const mockVSCodeLightTheme = {
|
||||
@@ -68,13 +60,6 @@ const mockVSCodeLightTheme = {
|
||||
"--vscode-activityWarningBadge-background": "#F9C20B",
|
||||
"--vscode-badge-background": "#007ACC",
|
||||
"--vscode-badge-foreground": "#FFFFFF",
|
||||
"--vscode-charts-green": "#73C991",
|
||||
"--vscode-charts-yellow": "#F9C20B",
|
||||
"--vscode-menu-background": "#F3F3F3",
|
||||
"--vscode-menu-border": "#D4D4D4",
|
||||
"--vscode-menu-foreground": "#6F6F6F",
|
||||
"--vscode-menu-selectionBackground": "#007ACC",
|
||||
"--vscode-menu-selectionForeground": "#FFFFFF",
|
||||
}
|
||||
|
||||
// Mock VSCode theme variables for Storybook
|
||||
|
||||
@@ -153,8 +153,8 @@ const createApiReqMessage = (minutesAgo: number, request: string, metrics: any =
|
||||
"api_req_started",
|
||||
JSON.stringify({
|
||||
request,
|
||||
tokensIn: 19500,
|
||||
tokensOut: 4220,
|
||||
tokensIn: 850,
|
||||
tokensOut: 420,
|
||||
cacheWrites: 120,
|
||||
cacheReads: 60,
|
||||
size: 12345,
|
||||
@@ -173,7 +173,7 @@ const mockActiveMessages: ClineMessage[] = [
|
||||
"I'll help you create a responsive navigation component for your React application. Let me start by examining your current project structure and then create a modern, accessible navigation component.",
|
||||
),
|
||||
createMessage(4.3, "say", "tool", JSON.stringify({ tool: "listFilesTopLevel", path: "src/components" })),
|
||||
createApiReqMessage(4.2, "Component creation request", { tokensIn: 12020, tokensOut: 6180, cost: 0.042 }),
|
||||
createApiReqMessage(4.2, "Component creation request", { tokensIn: 1200, tokensOut: 680, cost: 0.042 }),
|
||||
createMessage(
|
||||
4,
|
||||
"say",
|
||||
@@ -190,7 +190,7 @@ const mockActiveMessages: ClineMessage[] = [
|
||||
content: "// Navigation component code...",
|
||||
}),
|
||||
),
|
||||
createApiReqMessage(3.5, "Final response request", { tokensIn: 41550, tokensOut: 3320, cost: 0.018 }),
|
||||
createApiReqMessage(3.5, "Final response request", { tokensIn: 450, tokensOut: 320, cost: 0.018 }),
|
||||
createMessage(
|
||||
3.3,
|
||||
"say",
|
||||
@@ -213,8 +213,6 @@ const mockStreamingMessages: ClineMessage[] = [
|
||||
// Reusable state and decorator factories
|
||||
const createMockState = (overrides: any = {}) => ({
|
||||
...useExtensionState(),
|
||||
useAutoCondense: true,
|
||||
autoCondenseThreshold: 0.5,
|
||||
welcomeViewCompleted: true,
|
||||
showWelcome: false,
|
||||
clineMessages: mockActiveMessages,
|
||||
@@ -416,14 +414,14 @@ export const AutoApprovalEnabled: Story = {
|
||||
|
||||
const createPlanModeMessages = () => [
|
||||
createMessage(5, "say", "task", "Help me refactor my React application to use TypeScript and improve performance"),
|
||||
createApiReqMessage(4.9, "Planning analysis request", { tokensIn: 20000, tokensOut: 19500, cost: 0.065 }),
|
||||
createApiReqMessage(4.9, "Planning analysis request", { tokensIn: 1800, tokensOut: 950, cost: 0.065 }),
|
||||
createMessage(
|
||||
4.7,
|
||||
"say",
|
||||
"text",
|
||||
"I'll help you refactor your React application to use TypeScript and improve performance. Let me create a detailed plan for this migration.",
|
||||
),
|
||||
createApiReqMessage(4.5, "Detailed planning request", { tokensIn: 20002, tokensOut: 12500, cost: 0.095 }),
|
||||
createApiReqMessage(4.5, "Detailed planning request", { tokensIn: 2200, tokensOut: 1400, cost: 0.095 }),
|
||||
createAskMessage(
|
||||
"plan_mode_respond",
|
||||
"Here's my comprehensive plan for refactoring your React application with TypeScript migration and performance optimization phases.",
|
||||
|
||||
@@ -5,7 +5,7 @@ import DynamicTextArea from "react-textarea-autosize"
|
||||
import Thumbnails from "@/components/common/Thumbnails"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { CheckpointsServiceClient } from "@/services/grpc-client"
|
||||
import { highlightText } from "./task-header/Highlights"
|
||||
import { highlightText } from "./task-header/TaskHeader"
|
||||
|
||||
interface UserMessageProps {
|
||||
text?: string
|
||||
|
||||
@@ -44,7 +44,6 @@ export const TaskSection: React.FC<TaskSectionProps> = ({
|
||||
lastProgressMessageText={lastProgressMessageText}
|
||||
onClose={messageHandlers.handleTaskCloseButtonClick}
|
||||
onScrollToMessage={scrollBehavior.scrollToMessage}
|
||||
onSendMessage={messageHandlers.handleSendMessage}
|
||||
task={task}
|
||||
tokensIn={apiMetrics.totalTokensIn}
|
||||
tokensOut={apiMetrics.totalTokensOut}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { cn } from "@heroui/react"
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
export const AutoCondenseMarker: React.FC<{
|
||||
threshold: number
|
||||
usage: number
|
||||
isContextWindowHoverOpen?: boolean
|
||||
shouldAnimate?: boolean
|
||||
}> = ({ threshold, usage, isContextWindowHoverOpen, shouldAnimate = false }) => {
|
||||
const [isAnimating, setIsAnimating] = useState(false)
|
||||
const [animatedPosition, setAnimatedPosition] = useState(0)
|
||||
const [showPercentageAfterAnimation, setShowPercentageAfterAnimation] = useState(false)
|
||||
const [isFadingOut, setIsFadingOut] = useState(false)
|
||||
|
||||
// Refs to store animation frame and timeout IDs for cleanup
|
||||
const animationFrameRef = useRef<number | null>(null)
|
||||
const fadeOutTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Animation effect when shouldAnimate prop changes (initial load)
|
||||
useEffect(() => {
|
||||
// Cleanup function to cancel any pending animations or timeouts
|
||||
const cleanup = () => {
|
||||
if (animationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
animationFrameRef.current = null
|
||||
}
|
||||
if (fadeOutTimeoutRef.current !== null) {
|
||||
clearTimeout(fadeOutTimeoutRef.current)
|
||||
fadeOutTimeoutRef.current = null
|
||||
}
|
||||
if (hideTimeoutRef.current !== null) {
|
||||
clearTimeout(hideTimeoutRef.current)
|
||||
hideTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldAnimate && threshold > 0) {
|
||||
// Clean up any existing animations before starting new one
|
||||
cleanup()
|
||||
|
||||
setIsAnimating(true)
|
||||
const targetPosition = threshold * 100
|
||||
const duration = 1200 // ms - slowed down from 800ms
|
||||
const startTime = Date.now()
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
// Ease-out animation curve
|
||||
const easeOut = 1 - (1 - progress) ** 3
|
||||
const currentPosition = easeOut * targetPosition
|
||||
setAnimatedPosition(currentPosition)
|
||||
|
||||
if (progress < 1) {
|
||||
animationFrameRef.current = requestAnimationFrame(animate)
|
||||
} else {
|
||||
animationFrameRef.current = null
|
||||
setIsAnimating(false)
|
||||
setShowPercentageAfterAnimation(true)
|
||||
// Start fade out after 1 second
|
||||
fadeOutTimeoutRef.current = setTimeout(() => {
|
||||
setIsFadingOut(true)
|
||||
// Completely hide after fade transition
|
||||
hideTimeoutRef.current = setTimeout(() => {
|
||||
setShowPercentageAfterAnimation(false)
|
||||
setIsFadingOut(false)
|
||||
hideTimeoutRef.current = null
|
||||
setAnimatedPosition(threshold * 100) // Ensure it ends exactly at threshold
|
||||
}, 300) // 300ms fade duration
|
||||
fadeOutTimeoutRef.current = null
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
// Cleanup on unmount or when dependencies change
|
||||
return cleanup
|
||||
}, [shouldAnimate, threshold])
|
||||
|
||||
// The marker position is calculated based on the threshold percentage
|
||||
// It goes over the progress bar to indicate where the auto-condense will trigger
|
||||
// and it should highlight from what the current percentage (usage) is
|
||||
// to the threshold percentage
|
||||
const marker = useMemo(() => {
|
||||
const _threshold = threshold * 100
|
||||
// Always use the current threshold for position and label - animation only affects visual movement
|
||||
const position = _threshold
|
||||
const startingPosition = isAnimating ? animatedPosition : position
|
||||
|
||||
return {
|
||||
start: startingPosition + "%",
|
||||
label: startingPosition.toFixed(0),
|
||||
end: usage > startingPosition ? usage - startingPosition + "%" : 0,
|
||||
}
|
||||
}, [threshold, usage, isAnimating, animatedPosition])
|
||||
|
||||
if (!threshold) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1" id="auto-condense-threshold-marker">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 bottom-0 h-full cursor-pointer pointer-events-none z-10 bg-button-background shadow-lg w-1",
|
||||
{
|
||||
"transition-all duration-75": !isAnimating,
|
||||
},
|
||||
)}
|
||||
style={{
|
||||
left: marker.start,
|
||||
transform: isAnimating ? `translateX(${animatedPosition - threshold * 100}%)` : "translateX(0)",
|
||||
}}>
|
||||
{(isContextWindowHoverOpen || isAnimating || showPercentageAfterAnimation) && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -top-4 -left-1 text-button-background font-mono text-xs transition-opacity duration-300",
|
||||
{
|
||||
"opacity-0": isFadingOut,
|
||||
},
|
||||
)}>
|
||||
{marker.label}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
AutoCondenseMarker.displayName = "AutoCondenseMarker"
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Alert } from "@heroui/react"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { useMemo, useState } from "react"
|
||||
|
||||
interface CheckpointErrorProps {
|
||||
checkpointManagerErrorMessage?: string
|
||||
handleCheckpointSettingsClick: () => void
|
||||
}
|
||||
export const CheckpointError: React.FC<CheckpointErrorProps> = ({
|
||||
checkpointManagerErrorMessage,
|
||||
handleCheckpointSettingsClick,
|
||||
}) => {
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
|
||||
const messages = useMemo(() => {
|
||||
const message = checkpointManagerErrorMessage?.replace(/disabling checkpoints\.$/, "")
|
||||
const showDisableButton = checkpointManagerErrorMessage?.endsWith("disabling checkpoints.")
|
||||
const showGitInstructions = checkpointManagerErrorMessage?.includes("Git must be installed to use checkpoints.")
|
||||
return { message, showDisableButton, showGitInstructions }
|
||||
}, [checkpointManagerErrorMessage])
|
||||
|
||||
if (!checkpointManagerErrorMessage || dismissed) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full opacity-80 hover:opacity-100 transition-opacity duration-200">
|
||||
<Alert
|
||||
className="rounded-sm border-0 bg-[var(--vscode-inputValidation-errorBackground)] text-[var(--vscode-inputValidation-errorForeground)] p-1.5"
|
||||
color="warning"
|
||||
description={
|
||||
<div className="flex gap-2">
|
||||
{messages.showDisableButton && (
|
||||
<button
|
||||
className="underline cursor-pointer bg-transparent border-0 p-0 text-inherit"
|
||||
onClick={handleCheckpointSettingsClick}>
|
||||
Disable Checkpoints
|
||||
</button>
|
||||
)}
|
||||
{messages.showGitInstructions && (
|
||||
<a
|
||||
className="text-link underline"
|
||||
href="https://github.com/cline/cline/wiki/Installing-Git-for-Checkpoints">
|
||||
See instructions
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
endContent={
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
aria-label="Dismiss"
|
||||
className="inline-flex opacity-100 hover:bg-transparent hover:opacity-60 p-0"
|
||||
onClick={() => setDismissed(true)}
|
||||
title="Dismiss Checkpoint Error">
|
||||
<XIcon size={12} />
|
||||
</VSCodeButton>
|
||||
}
|
||||
hideIconWrapper={true}
|
||||
isVisible={!dismissed}
|
||||
title={messages.message}
|
||||
variant="faded"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
import { cn, Progress, Tooltip } from "@heroui/react"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import debounce from "debounce"
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { updateSetting } from "@/components/settings/utils/settingsHandlers"
|
||||
import { formatLargeNumber as formatTokenNumber } from "@/utils/format"
|
||||
import { AutoCondenseMarker } from "./AutoCondenseMarker"
|
||||
import CompactTaskButton from "./buttons/CompactTaskButton"
|
||||
import { ContextWindowSummary } from "./ContextWindowSummary"
|
||||
|
||||
// Type definitions
|
||||
interface ContextWindowInfoProps {
|
||||
tokensIn?: number
|
||||
tokensOut?: number
|
||||
cacheWrites?: number
|
||||
cacheReads?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
interface ContextWindowProgressProps extends ContextWindowInfoProps {
|
||||
useAutoCondense: boolean
|
||||
lastApiReqTotalTokens?: number
|
||||
contextWindow?: number
|
||||
autoCondenseThreshold?: number
|
||||
onSendMessage?: (command: string, files: string[], images: string[]) => void
|
||||
}
|
||||
|
||||
const ConfirmationDialog = memo<{
|
||||
onConfirm: (e: React.MouseEvent) => void
|
||||
onCancel: (e: React.MouseEvent) => void
|
||||
}>(({ onConfirm, onCancel }) => (
|
||||
<div className="text-xs my-2 flex items-center gap-0 justify-between">
|
||||
<span className="font-semibold text-sm">Compact the current task?</span>
|
||||
<span className="flex gap-1">
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
className="text-xs"
|
||||
onClick={onCancel}
|
||||
title="No, keep the task as is"
|
||||
type="button">
|
||||
Cancel
|
||||
</VSCodeButton>
|
||||
<VSCodeButton
|
||||
appearance="primary"
|
||||
autoFocus={true}
|
||||
className="text-xs"
|
||||
onClick={onConfirm}
|
||||
title="Yes, compact the task"
|
||||
type="button">
|
||||
Yes
|
||||
</VSCodeButton>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
ConfirmationDialog.displayName = "ConfirmationDialog"
|
||||
|
||||
const ContextWindow: React.FC<ContextWindowProgressProps> = ({
|
||||
contextWindow = 0,
|
||||
lastApiReqTotalTokens = 0,
|
||||
autoCondenseThreshold = 0.75,
|
||||
onSendMessage,
|
||||
useAutoCondense,
|
||||
tokensIn,
|
||||
tokensOut,
|
||||
cacheWrites,
|
||||
cacheReads,
|
||||
}) => {
|
||||
const [isOpened, setIsOpened] = useState(false)
|
||||
const [threshold, setThreshold] = useState(useAutoCondense ? autoCondenseThreshold : 0)
|
||||
const [confirmationNeeded, setConfirmationNeeded] = useState(false)
|
||||
const progressBarRef = useRef<HTMLDivElement>(null)
|
||||
const [shouldAnimateMarker, setShouldAnimateMarker] = useState(false)
|
||||
|
||||
// Trigger marker animation when component first mounts (TaskHeader expands)
|
||||
useEffect(() => {
|
||||
if (useAutoCondense && threshold > 0) {
|
||||
setShouldAnimateMarker(true)
|
||||
// Reset animation flag after animation completes
|
||||
const timer = setTimeout(() => {
|
||||
setShouldAnimateMarker(false)
|
||||
}, 1400) // Slightly longer than animation duration (1200ms + buffer)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, []) // Empty dependency array means this only runs on mount
|
||||
|
||||
const handleContextWindowBarClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const clickX = event.clientX - rect.left
|
||||
const percentage = Math.max(0, Math.min(1, clickX / rect.width))
|
||||
const newThreshold = Math.round(percentage * 100) / 100
|
||||
setConfirmationNeeded(false)
|
||||
setThreshold(newThreshold)
|
||||
updateSetting("autoCondenseThreshold", newThreshold)
|
||||
}, [])
|
||||
|
||||
const handleCompactClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setConfirmationNeeded(!confirmationNeeded)
|
||||
},
|
||||
[confirmationNeeded],
|
||||
)
|
||||
|
||||
const handleConfirm = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onSendMessage?.("/compact", [], [])
|
||||
setConfirmationNeeded(false)
|
||||
},
|
||||
[onSendMessage],
|
||||
)
|
||||
|
||||
const handleCancel = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setConfirmationNeeded(false)
|
||||
}, [])
|
||||
|
||||
const tokenData = useMemo(() => {
|
||||
if (!contextWindow) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
percentage: (lastApiReqTotalTokens / contextWindow) * 100,
|
||||
max: formatTokenNumber(contextWindow),
|
||||
used: formatTokenNumber(lastApiReqTotalTokens),
|
||||
}
|
||||
}, [contextWindow, lastApiReqTotalTokens])
|
||||
|
||||
const debounceCloseHover = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const showHover = debounce((open: boolean) => setIsOpened(open), 100)
|
||||
|
||||
return showHover(false)
|
||||
}, [])
|
||||
|
||||
// Keyboard event handlers
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!useAutoCondense) {
|
||||
return
|
||||
}
|
||||
|
||||
const step = event.shiftKey ? 0.1 : 0.05 // Larger step with Shift
|
||||
let newThreshold = threshold
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowLeft":
|
||||
case "ArrowDown":
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setIsOpened(true) // Keep tooltip open on interaction
|
||||
newThreshold = Math.max(0, threshold - step)
|
||||
break
|
||||
case "ArrowRight":
|
||||
case "ArrowUp":
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setIsOpened(true) // Keep tooltip open on interaction
|
||||
newThreshold = Math.min(1, threshold + step)
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
if (newThreshold !== threshold) {
|
||||
setThreshold(newThreshold)
|
||||
updateSetting("autoCondenseThreshold", newThreshold)
|
||||
}
|
||||
},
|
||||
[threshold, useAutoCondense, setIsOpened],
|
||||
)
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setIsOpened(true)
|
||||
}, [])
|
||||
|
||||
// Close tooltip when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Element
|
||||
const isInsideProgressBar = progressBarRef.current && progressBarRef.current.contains(target as Node)
|
||||
|
||||
// Check if click is inside any tooltip content by looking for our custom class
|
||||
const isInsideTooltipContent = target.closest(".context-window-tooltip-content") !== null
|
||||
|
||||
if (!isInsideProgressBar && !isInsideTooltipContent) {
|
||||
setIsOpened(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpened) {
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
}, [isOpened])
|
||||
|
||||
if (!tokenData) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col my-1.5" onMouseLeave={debounceCloseHover}>
|
||||
<div className="flex gap-1 flex-row @max-xs:flex-col @max-xs:items-start items-center text-sm">
|
||||
<div className="flex items-center gap-1.5 flex-1 whitespace-nowrap text-xs">
|
||||
<span className="cursor-pointer" title="Current tokens used in this request">
|
||||
{tokenData.used}
|
||||
</span>
|
||||
<div className="flex relative items-center gap-1 flex-1 w-full h-full" onMouseEnter={() => setIsOpened(true)}>
|
||||
<Tooltip
|
||||
closeDelay={0}
|
||||
content={
|
||||
<ContextWindowSummary
|
||||
autoCompactThreshold={useAutoCondense ? threshold : undefined}
|
||||
cacheReads={cacheReads}
|
||||
cacheWrites={cacheWrites}
|
||||
contextWindow={tokenData.max}
|
||||
percentage={tokenData.percentage}
|
||||
tokensIn={tokensIn}
|
||||
tokensOut={tokensOut}
|
||||
tokenUsed={tokenData.used}
|
||||
/>
|
||||
}
|
||||
disableAnimation={true}
|
||||
isOpen={isOpened}
|
||||
offset={-2}
|
||||
placement="bottom"
|
||||
shouldCloseOnBlur={false}
|
||||
shouldCloseOnInteractOutside={() => false}
|
||||
showArrow={true}>
|
||||
<div
|
||||
aria-label="Auto condense threshold"
|
||||
aria-valuemax={100}
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={Math.round(threshold * 100)}
|
||||
aria-valuetext={`${Math.round(threshold * 100)}% threshold`}
|
||||
className="relative w-full text-badge-foreground context-window-progress brightness-100"
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={progressBarRef}
|
||||
role="slider"
|
||||
tabIndex={useAutoCondense ? 0 : -1}>
|
||||
<Progress
|
||||
aria-label="Context window usage progress"
|
||||
classNames={{
|
||||
base: "drop-shadow-md w-full cursor-pointer",
|
||||
track: cn("rounded max-h-2 h-3 bg-foreground/10"),
|
||||
indicator: "bg-foreground rounded-r",
|
||||
label: "tracking-wider font-medium text-foreground/80",
|
||||
value: "text-description",
|
||||
}}
|
||||
color="success"
|
||||
disableAnimation={true}
|
||||
onClick={handleContextWindowBarClick}
|
||||
size="md"
|
||||
value={tokenData.percentage}
|
||||
/>
|
||||
{useAutoCondense && (
|
||||
<AutoCondenseMarker
|
||||
isContextWindowHoverOpen={isOpened}
|
||||
shouldAnimate={shouldAnimateMarker}
|
||||
threshold={threshold}
|
||||
usage={tokenData.percentage}
|
||||
/>
|
||||
)}
|
||||
{isOpened}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span className="cursor-pointer" title="Maximum context window size for this model">
|
||||
{tokenData.max}
|
||||
</span>
|
||||
</div>
|
||||
<CompactTaskButton onClick={handleCompactClick} />
|
||||
</div>
|
||||
{confirmationNeeded && <ConfirmationDialog onCancel={handleCancel} onConfirm={handleConfirm} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ContextWindow)
|
||||
@@ -1,184 +0,0 @@
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"
|
||||
import React, { memo, useCallback, useMemo, useState } from "react"
|
||||
import { formatLargeNumber as formatTokenNumber } from "@/utils/format"
|
||||
|
||||
interface TokenUsageInfoProps {
|
||||
tokensIn?: number
|
||||
tokensOut?: number
|
||||
cacheWrites?: number
|
||||
cacheReads?: number
|
||||
}
|
||||
|
||||
interface TokenDetail {
|
||||
title: string
|
||||
value?: number
|
||||
icon: string
|
||||
}
|
||||
|
||||
interface TaskContextWindowButtonsProps extends TokenUsageInfoProps {
|
||||
percentage: number
|
||||
tokenUsed: string
|
||||
contextWindow: string
|
||||
autoCompactThreshold?: number
|
||||
isThresholdChanged?: boolean
|
||||
isThresholdFadingOut?: boolean
|
||||
}
|
||||
|
||||
// New accordion item component
|
||||
const AccordionItem = memo<{
|
||||
title: string
|
||||
value: React.ReactNode
|
||||
isExpanded: boolean
|
||||
onToggle: (event?: React.MouseEvent) => void
|
||||
children?: React.ReactNode
|
||||
}>(({ title, value, isExpanded, onToggle, children }) => {
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onToggle(event)
|
||||
},
|
||||
[onToggle],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div
|
||||
className="flex justify-between items-center gap-3 cursor-pointer hover:bg-foreground/5 rounded px-1 py-0.5 transition-colors"
|
||||
onClick={handleClick}>
|
||||
<div className="flex items-center gap-1">
|
||||
{isExpanded ? <ChevronDownIcon size={12} /> : <ChevronRightIcon size={12} />}
|
||||
<div className="font-semibold text-sm">{title}</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">{value}</div>
|
||||
</div>
|
||||
{isExpanded && children && <div className="ml-4 mt-2 mb-1 text-xs text-muted-foreground">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
// Constants
|
||||
const TOKEN_DETAILS_CONFIG: Omit<TokenDetail, "value">[] = [
|
||||
{ title: "Prompt Tokens", icon: "codicon-arrow-up" },
|
||||
{ title: "Completion Tokens", icon: "codicon-arrow-down" },
|
||||
{ title: "Cache Writes", icon: "codicon-arrow-left" },
|
||||
{ title: "Cache Reads", icon: "codicon-arrow-right" },
|
||||
]
|
||||
|
||||
const TokenUsageDetails = memo<TokenUsageInfoProps>(({ tokensIn, tokensOut, cacheWrites, cacheReads }) => {
|
||||
const contextTokenDetails = useMemo(() => {
|
||||
const values = [tokensIn, tokensOut, cacheWrites || 0, cacheReads || 0]
|
||||
return TOKEN_DETAILS_CONFIG.map((config, index) => ({ ...config, value: values[index] })).filter((item) => item.value)
|
||||
}, [tokensIn, tokensOut, cacheWrites, cacheReads])
|
||||
|
||||
if (!tokensIn) {
|
||||
return <div>No token usage data available</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{contextTokenDetails.map((item) => (
|
||||
<div className="flex items-center justify-between" key={item.icon}>
|
||||
<div className="flex items-center gap-1">
|
||||
<i className={`codicon ${item.icon} text-xs`} />
|
||||
<span>{item.title}</span>
|
||||
</div>
|
||||
<span className="font-mono">{formatTokenNumber(item.value || 0)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
TokenUsageDetails.displayName = "TokenUsageDetails"
|
||||
|
||||
export const ContextWindowSummary: React.FC<TaskContextWindowButtonsProps> = ({
|
||||
contextWindow,
|
||||
tokenUsed,
|
||||
tokensIn,
|
||||
tokensOut,
|
||||
cacheWrites,
|
||||
cacheReads,
|
||||
percentage,
|
||||
autoCompactThreshold = 0,
|
||||
}) => {
|
||||
// Accordion state
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set())
|
||||
|
||||
const toggleSection = useCallback((section: string, event?: React.MouseEvent) => {
|
||||
if (event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
setExpandedSections((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(section)) {
|
||||
newSet.delete(section)
|
||||
} else {
|
||||
newSet.add(section)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}, [])
|
||||
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0)
|
||||
|
||||
return (
|
||||
<div className="context-window-tooltip-content flex flex-col gap-2 bg-menu rounded shadow-sm border border-menu-border z-100 w-60 p-4">
|
||||
{autoCompactThreshold > 0 && (
|
||||
<AccordionItem
|
||||
isExpanded={expandedSections.has("threshold")}
|
||||
onToggle={(event) => toggleSection("threshold", event)}
|
||||
title="Auto Condense Threshold"
|
||||
value={<span className="text-muted-foreground">{`${(autoCompactThreshold * 100).toFixed(0)}%`}</span>}>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs leading-relaxed text-white">
|
||||
Click on the context window bar to set a new threshold.
|
||||
</p>
|
||||
<p className="text-xs leading-relaxed mt-0 mb-0">
|
||||
When the context window usage exceeds this threshold, the task will be automatically condensed.
|
||||
</p>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
<AccordionItem
|
||||
isExpanded={expandedSections.has("context")}
|
||||
onToggle={(event) => toggleSection("context", event)}
|
||||
title="Context Window"
|
||||
value={percentage ? `${percentage.toFixed(1)}% used` : contextWindow}>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Used:</span>
|
||||
<span className="font-mono">{tokenUsed}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Total:</span>
|
||||
<span className="font-mono">{contextWindow}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Remaining:</span>
|
||||
<span className="font-mono">
|
||||
{formatTokenNumber(parseInt(contextWindow.replace(/,/g, "")) - parseInt(tokenUsed.replace(/,/g, "")))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
|
||||
{totalTokens > 0 && (
|
||||
<AccordionItem
|
||||
isExpanded={expandedSections.has("tokens")}
|
||||
onToggle={(event) => toggleSection("tokens", event)}
|
||||
title="Token Usage"
|
||||
value={`${formatTokenNumber(totalTokens)} total`}>
|
||||
<TokenUsageDetails
|
||||
cacheReads={cacheReads}
|
||||
cacheWrites={cacheWrites}
|
||||
tokensIn={tokensIn}
|
||||
tokensOut={tokensOut}
|
||||
/>
|
||||
</AccordionItem>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
import { cn } from "@heroui/react"
|
||||
import { isCompletedFocusChainItem, isFocusChainItem } from "@shared/focus-chain-utils"
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"
|
||||
import React, { memo, useCallback, useMemo, useState } from "react"
|
||||
import ChecklistRenderer from "@/components/common/ChecklistRenderer"
|
||||
import { FileServiceClient } from "@/services/grpc-client"
|
||||
|
||||
// Optimized interface with readonly properties to prevent accidental mutations
|
||||
interface TodoInfo {
|
||||
readonly currentTodo: { text: string; completed: boolean; index: number } | null
|
||||
readonly currentIndex: number
|
||||
readonly completedCount: number
|
||||
readonly totalCount: number
|
||||
readonly progressPercentage: number
|
||||
}
|
||||
|
||||
interface FocusChainProps {
|
||||
readonly lastProgressMessageText?: string
|
||||
readonly currentTaskItemId?: string
|
||||
}
|
||||
|
||||
// Static strings to avoid recreating them
|
||||
const COMPLETED_MESSAGE = "All tasks have been completed!"
|
||||
const TODO_LIST_LABEL = "To-Do list"
|
||||
const NEW_STEPS_MESSAGE = "New steps will be generated if you continue the task"
|
||||
const CLICK_TO_EDIT_TITLE = "Click to edit to-do list in file"
|
||||
|
||||
// Optimized header component with minimal re-renders
|
||||
const ToDoListHeader = memo<{
|
||||
todoInfo: TodoInfo
|
||||
isExpanded: boolean
|
||||
}>(({ todoInfo, isExpanded }) => {
|
||||
const { currentTodo, currentIndex, totalCount, completedCount, progressPercentage } = todoInfo
|
||||
const isCompleted = completedCount === totalCount
|
||||
|
||||
// Pre-compute display text
|
||||
const displayText = isCompleted ? COMPLETED_MESSAGE : currentTodo?.text || TODO_LIST_LABEL
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative w-full h-full", {
|
||||
"text-success": isCompleted,
|
||||
})}>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-0 left-0 transition-[width] duration-300 ease-in-out pointer-events-none z-1 h-1 bg-success",
|
||||
{
|
||||
"opacity-0": progressPercentage === 0 || progressPercentage === 100,
|
||||
},
|
||||
)}
|
||||
style={{
|
||||
width: `${100 - progressPercentage}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 z-10 py-2.5 px-1.5">
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-lg px-2 py-0.25 text-xs inline-block shrink-0 bg-badge-foreground/20 text-badge-foreground",
|
||||
{
|
||||
"bg-success text-black": isCompleted,
|
||||
},
|
||||
)}>
|
||||
{currentIndex}/{totalCount}
|
||||
</span>
|
||||
<span className="header-text text-xs font-medium break-words overflow-hidden text-ellipsis whitespace-nowrap max-w-[calc(100%-60px)]">
|
||||
{displayText}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-foreground">
|
||||
{isExpanded ? <ChevronDownIcon className="ml-0.25" size="16" /> : <ChevronRightIcon size="16" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
ToDoListHeader.displayName = "ToDoListHeader"
|
||||
|
||||
// Cache for parsed todo info to avoid re-parsing identical text
|
||||
const todoInfoCache = new Map<string, TodoInfo | null>()
|
||||
const MAX_CACHE_SIZE = 100
|
||||
|
||||
// Highly optimized parsing with minimal allocations
|
||||
const parseCurrentTodoInfo = (text: string): TodoInfo | null => {
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
const cached = todoInfoCache.get(text)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
let completedCount = 0
|
||||
let totalCount = 0
|
||||
let firstIncompleteIndex = -1
|
||||
let firstIncompleteText: string | null = null
|
||||
|
||||
// Process text line by line without creating intermediate arrays
|
||||
let lineStart = 0
|
||||
let lineEnd = text.indexOf("\n")
|
||||
|
||||
while (lineStart < text.length) {
|
||||
const line = lineEnd === -1 ? text.substring(lineStart).trim() : text.substring(lineStart, lineEnd).trim()
|
||||
|
||||
if (isFocusChainItem(line)) {
|
||||
const isCompleted = isCompletedFocusChainItem(line)
|
||||
|
||||
if (isCompleted) {
|
||||
completedCount++
|
||||
} else if (firstIncompleteIndex === -1) {
|
||||
firstIncompleteIndex = totalCount
|
||||
// Extract text only for the first incomplete item
|
||||
firstIncompleteText = line.substring(5).trim()
|
||||
}
|
||||
|
||||
totalCount++
|
||||
}
|
||||
|
||||
if (lineEnd === -1) {
|
||||
break
|
||||
}
|
||||
lineStart = lineEnd + 1
|
||||
lineEnd = text.indexOf("\n", lineStart)
|
||||
}
|
||||
|
||||
if (totalCount === 0) {
|
||||
todoInfoCache.set(text, null)
|
||||
return null
|
||||
}
|
||||
|
||||
const currentTodo = firstIncompleteText ? { text: firstIncompleteText, completed: false, index: firstIncompleteIndex } : null
|
||||
|
||||
const result: TodoInfo = {
|
||||
currentTodo,
|
||||
currentIndex: firstIncompleteIndex >= 0 ? firstIncompleteIndex + 1 : totalCount,
|
||||
completedCount,
|
||||
totalCount,
|
||||
progressPercentage: (completedCount / totalCount) * 100,
|
||||
}
|
||||
|
||||
// Cache the result with size management
|
||||
if (todoInfoCache.size >= MAX_CACHE_SIZE) {
|
||||
// Remove oldest entry (first key)
|
||||
const firstKey = todoInfoCache.keys().next().value
|
||||
if (firstKey) {
|
||||
todoInfoCache.delete(firstKey)
|
||||
}
|
||||
}
|
||||
todoInfoCache.set(text, result)
|
||||
return result
|
||||
}
|
||||
|
||||
// Main component with aggressive optimization
|
||||
export const FocusChain: React.FC<FocusChainProps> = memo(
|
||||
({ currentTaskItemId, lastProgressMessageText }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
// Parse todo info with caching
|
||||
const todoInfo = useMemo(
|
||||
() => (lastProgressMessageText ? parseCurrentTodoInfo(lastProgressMessageText) : null),
|
||||
[lastProgressMessageText],
|
||||
)
|
||||
|
||||
// Static callbacks that don't change
|
||||
const handleToggle = useCallback(() => setIsExpanded((prev) => !prev), [])
|
||||
|
||||
const handleEditClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (currentTaskItemId) {
|
||||
FileServiceClient.openFocusChainFile(StringRequest.create({ value: currentTaskItemId }))
|
||||
}
|
||||
},
|
||||
[currentTaskItemId],
|
||||
)
|
||||
|
||||
// Early return for no content
|
||||
if (!todoInfo) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isCompleted = todoInfo.completedCount === todoInfo.totalCount
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative rounded-sm bg-toolbar-hover/65 flex flex-col gap-1.5 select-none hover:bg-toolbar-hover overflow-hidden opacity-80 hover:opacity-100 transition-all duration-200 cursor-pointer"
|
||||
onClick={handleToggle}
|
||||
title={CLICK_TO_EDIT_TITLE}>
|
||||
<ToDoListHeader isExpanded={isExpanded} todoInfo={todoInfo} />
|
||||
{isExpanded && (
|
||||
<div className="mx-1 pb-2 px-1 relative" onClick={handleEditClick}>
|
||||
<ChecklistRenderer text={lastProgressMessageText!} />
|
||||
{isCompleted && (
|
||||
<div className="mt-2 text-xs font-semibold text-muted-foreground">{NEW_STEPS_MESSAGE}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison for better performance
|
||||
return (
|
||||
prevProps.lastProgressMessageText === nextProps.lastProgressMessageText &&
|
||||
prevProps.currentTaskItemId === nextProps.currentTaskItemId
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
FocusChain.displayName = "FocusChain"
|
||||
@@ -1,76 +0,0 @@
|
||||
import { mentionRegexGlobal } from "@shared/context-mentions"
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import { FileServiceClient } from "@/services/grpc-client"
|
||||
import { validateSlashCommand } from "@/utils/slash-commands"
|
||||
|
||||
// Optimized highlighting functions
|
||||
const highlightSlashCommands = (text: string, withShadow = true) => {
|
||||
const match = text.match(/^\s*\/([a-zA-Z0-9_-]+)(\s*|$)/)
|
||||
if (!match || validateSlashCommand(match[1]) !== "full") {
|
||||
return text
|
||||
}
|
||||
|
||||
const commandName = match[1]
|
||||
const commandEndIndex = match[0].length
|
||||
const beforeCommand = text.substring(0, text.indexOf("/"))
|
||||
const afterCommand = match[2] + text.substring(commandEndIndex)
|
||||
|
||||
return [
|
||||
beforeCommand,
|
||||
<span className={withShadow ? "mention-context-highlight-with-shadow" : "mention-context-highlight"} key="slashCommand">
|
||||
/{commandName}
|
||||
</span>,
|
||||
afterCommand,
|
||||
]
|
||||
}
|
||||
|
||||
export const highlightMentions = (text: string, withShadow = true) => {
|
||||
if (!mentionRegexGlobal.test(text)) {
|
||||
return text
|
||||
}
|
||||
|
||||
const parts = text.split(mentionRegexGlobal)
|
||||
const result: (string | JSX.Element)[] = []
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (i % 2 === 0) {
|
||||
if (parts[i]) {
|
||||
result.push(parts[i])
|
||||
}
|
||||
} else {
|
||||
result.push(
|
||||
<span
|
||||
className={`${withShadow ? "mention-context-highlight-with-shadow" : "mention-context-highlight"} cursor-pointer`}
|
||||
key={`mention-${Math.floor(i / 2)}`}
|
||||
onClick={() => FileServiceClient.openMention(StringRequest.create({ value: parts[i] }))}>
|
||||
@{parts[i]}
|
||||
</span>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return result.length === 1 ? result[0] : result
|
||||
}
|
||||
|
||||
export const highlightText = (text?: string, withShadow = true) => {
|
||||
if (!text) {
|
||||
return text
|
||||
}
|
||||
|
||||
const slashResult = highlightSlashCommands(text, withShadow)
|
||||
|
||||
if (slashResult === text) {
|
||||
return highlightMentions(text, withShadow)
|
||||
}
|
||||
|
||||
if (Array.isArray(slashResult) && slashResult.length === 3) {
|
||||
const [beforeCommand, commandElement, afterCommand] = slashResult as [string, JSX.Element, string]
|
||||
const mentionResult = highlightMentions(afterCommand, withShadow)
|
||||
|
||||
return Array.isArray(mentionResult)
|
||||
? [beforeCommand, commandElement, ...mentionResult]
|
||||
: [beforeCommand, commandElement, mentionResult]
|
||||
}
|
||||
|
||||
return slashResult
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,9 +8,9 @@ import TaskTimelineTooltip from "./TaskTimelineTooltip"
|
||||
import { getColor } from "./util"
|
||||
|
||||
// Timeline dimensions and spacing
|
||||
const TIMELINE_HEIGHT = "12px"
|
||||
const TIMELINE_HEIGHT = "13px"
|
||||
const BLOCK_WIDTH = "13px"
|
||||
const BLOCK_GAP = "4px"
|
||||
const BLOCK_GAP = "3px"
|
||||
const _TOOLTIP_MARGIN = 32 // 32px margin on each side
|
||||
|
||||
interface TaskTimelineProps {
|
||||
@@ -21,7 +21,6 @@ interface TaskTimelineProps {
|
||||
const TaskTimeline: React.FC<TaskTimelineProps> = ({ messages, onBlockClick }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const scrollableRef = useRef<HTMLDivElement>(null)
|
||||
const [hoveredIndex, setHoveredIndex] = React.useState<number | null>(null)
|
||||
|
||||
const { taskTimelinePropsMessages, messageIndexMap } = useMemo(() => {
|
||||
if (messages.length <= 1) {
|
||||
@@ -104,22 +103,10 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ messages, onBlockClick }) =
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHoveredIndex(index)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredIndex(null)
|
||||
}
|
||||
|
||||
const isHovered = hoveredIndex === index
|
||||
|
||||
return (
|
||||
<TaskTimelineTooltip message={message}>
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={{
|
||||
width: BLOCK_WIDTH,
|
||||
height: "100%",
|
||||
@@ -127,14 +114,12 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ messages, onBlockClick }) =
|
||||
flexShrink: 0,
|
||||
cursor: "pointer",
|
||||
marginRight: BLOCK_GAP,
|
||||
opacity: isHovered ? 0.7 : 1,
|
||||
transition: "opacity 0.2s ease",
|
||||
}}
|
||||
/>
|
||||
</TaskTimelineTooltip>
|
||||
)
|
||||
},
|
||||
[taskTimelinePropsMessages, messageIndexMap, onBlockClick, hoveredIndex],
|
||||
[taskTimelinePropsMessages, messageIndexMap, onBlockClick],
|
||||
)
|
||||
|
||||
// Scroll to the end when messages change
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { cn, Tooltip } from "@heroui/react"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { FoldVerticalIcon } from "lucide-react"
|
||||
|
||||
const CompactTaskButton: React.FC<{
|
||||
className?: string
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
}> = ({ onClick, className }) => {
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="flex flex-col gap-1.5 bg-menu rounded shadow-sm border border-menu-border z-100 max-w-xs p-4">
|
||||
<div className="text-sm font-medium">Compact Task</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Reduces the number of tokens used by summarizing the task. To enable automatic condensing, turn on{" "}
|
||||
<kbd>Auto Compact</kbd> in the settings and set the threshold by clicking on the context window usage bar.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
placement="bottom">
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
className={cn(
|
||||
"text-badge-foreground flex items-center text-sm font-bold hover:bg-transparent hover:opacity-80",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
type="button">
|
||||
<FoldVerticalIcon size={12} />
|
||||
</VSCodeButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default CompactTaskButton
|
||||
@@ -1,16 +1,13 @@
|
||||
import { Button } from "@heroui/button"
|
||||
import { cn } from "@heroui/react"
|
||||
import { CheckIcon, CopyIcon } from "lucide-react"
|
||||
import { useCallback, useState } from "react"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useState } from "react"
|
||||
import HeroTooltip from "@/components/common/HeroTooltip"
|
||||
|
||||
const CopyTaskButton: React.FC<{
|
||||
taskText?: string
|
||||
className?: string
|
||||
}> = ({ taskText, className }) => {
|
||||
}> = ({ taskText }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
const handleCopy = () => {
|
||||
if (!taskText) {
|
||||
return
|
||||
}
|
||||
@@ -19,19 +16,20 @@ const CopyTaskButton: React.FC<{
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
})
|
||||
}, [taskText])
|
||||
}
|
||||
|
||||
return (
|
||||
<HeroTooltip content="Copy Text" placement="right">
|
||||
<Button
|
||||
aria-label="Copy"
|
||||
className={cn("bg-transparent hover:opacity-100", className)}
|
||||
isIconOnly={true}
|
||||
onPress={() => handleCopy()}
|
||||
radius="sm"
|
||||
size="sm">
|
||||
{copied ? <CheckIcon size="14" /> : <CopyIcon size="14" />}
|
||||
</Button>
|
||||
<HeroTooltip content="Copy Task">
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
aria-label="Copy Task"
|
||||
className="p-0"
|
||||
onClick={handleCopy}
|
||||
style={{ padding: "0px 0px" }}>
|
||||
<div className="flex items-center gap-[3px] text-[8px] font-bold opacity-60">
|
||||
<i className={`codicon codicon-${copied ? "check" : "copy"}`} />
|
||||
</div>
|
||||
</VSCodeButton>
|
||||
</HeroTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
import { Button, cn } from "@heroui/react"
|
||||
import { StringArrayRequest } from "@shared/proto/cline/common"
|
||||
import { TrashIcon } from "lucide-react"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import HeroTooltip from "@/components/common/HeroTooltip"
|
||||
import { TaskServiceClient } from "@/services/grpc-client"
|
||||
import { formatSize } from "@/utils/format"
|
||||
|
||||
const DeleteTaskButton: React.FC<{
|
||||
taskSize: string
|
||||
taskId?: string
|
||||
taskSize?: number
|
||||
className?: string
|
||||
}> = ({ taskId, className, taskSize }) => (
|
||||
<HeroTooltip content={`Delete Task (size: ${taskSize ? formatSize(taskSize) : "--"})`} placement="right">
|
||||
<Button
|
||||
aria-label="Delete Task"
|
||||
className={cn("flex items-center border-0 text-sm font-bold bg-transparent hover:opacity-100 p-0", className)}
|
||||
isIconOnly={true}
|
||||
onPress={() => {
|
||||
taskId && TaskServiceClient.deleteTasksWithIds(StringArrayRequest.create({ value: [taskId] }))
|
||||
}}
|
||||
radius="sm"
|
||||
size="sm">
|
||||
<TrashIcon size="14" />
|
||||
</Button>
|
||||
}> = ({ taskSize, taskId }) => (
|
||||
<HeroTooltip content="Delete Task">
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
aria-label="Delete task"
|
||||
onClick={() => taskId && TaskServiceClient.deleteTasksWithIds(StringArrayRequest.create({ value: [taskId] }))}
|
||||
style={{ padding: "0px 0px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "3px",
|
||||
fontSize: "10px",
|
||||
fontWeight: "bold",
|
||||
opacity: 0.6,
|
||||
}}>
|
||||
<i className={`codicon codicon-trash`} />
|
||||
{taskSize}
|
||||
</div>
|
||||
</VSCodeButton>
|
||||
</HeroTooltip>
|
||||
)
|
||||
DeleteTaskButton.displayName = "DeleteTaskButton"
|
||||
|
||||
export default DeleteTaskButton
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { cn } from "@heroui/react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import HeroTooltip from "@/components/common/HeroTooltip"
|
||||
|
||||
const NewTaskButton: React.FC<{
|
||||
onClick: () => void
|
||||
className?: string
|
||||
}> = ({ className, onClick }) => {
|
||||
return (
|
||||
<HeroTooltip content="Close current task to start new one" delay={1000} placement="bottom">
|
||||
<button
|
||||
aria-label="Start a New Task"
|
||||
className={cn(
|
||||
"flex items-center border-0 text-sm font-bold bg-transparent opacity-70 hover:opacity-100",
|
||||
className,
|
||||
"hover:bg-muted/10 px-0 cursor-pointer",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClick()
|
||||
}}
|
||||
type="button">
|
||||
<XIcon size="14" />
|
||||
</button>
|
||||
</HeroTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewTaskButton
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Button, cn } from "@heroui/react"
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import { ArrowDownToLineIcon } from "lucide-react"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import HeroTooltip from "@/components/common/HeroTooltip"
|
||||
import { FileServiceClient } from "@/services/grpc-client"
|
||||
|
||||
const OpenDiskTaskHistoryButton: React.FC<{
|
||||
taskId?: string
|
||||
className?: string
|
||||
}> = ({ taskId, className }) => {
|
||||
}> = ({ taskId }) => {
|
||||
const handleOpenDiskTaskHistory = () => {
|
||||
if (!taskId) {
|
||||
return
|
||||
@@ -18,16 +17,18 @@ const OpenDiskTaskHistoryButton: React.FC<{
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Open Disk Task History"
|
||||
className={cn("flex items-center border-0 text-sm font-bold bg-transparent hover:opacity-100", className)}
|
||||
isIconOnly={true}
|
||||
onPress={() => handleOpenDiskTaskHistory()}
|
||||
radius="sm"
|
||||
size="sm"
|
||||
title="Export Task">
|
||||
<ArrowDownToLineIcon size="14" />
|
||||
</Button>
|
||||
<HeroTooltip content="Open Disk Task History">
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
aria-label="Open Disk Task History"
|
||||
className="p-0"
|
||||
onClick={handleOpenDiskTaskHistory}
|
||||
style={{ padding: "0px 0px" }}>
|
||||
<div className="flex items-center gap-[3px] text-[8px] font-bold opacity-60">
|
||||
<i className={`codicon codicon-folder`} />
|
||||
</div>
|
||||
</VSCodeButton>
|
||||
</HeroTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { cn } from "@heroui/react"
|
||||
import { parseFocusChainItem } from "@shared/focus-chain-utils"
|
||||
import { CheckIcon, CircleIcon } from "lucide-react"
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
interface ChecklistRendererProps {
|
||||
@@ -104,18 +102,31 @@ const ChecklistRenderer: React.FC<ChecklistRendererProps> = ({ text }) => {
|
||||
overflowY: items.length >= 10 ? "auto" : "visible",
|
||||
}}>
|
||||
{items.map((item, index) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: Using index as key for checklist items
|
||||
<div className="flex *:items-start gap-1.5 p-0.5" key={`checklist-item-${index}`}>
|
||||
<span className={cn("text-xs shrink-0", item.checked ? "text-success" : "text-badge-foreground")}>
|
||||
{item.checked ? <CheckIcon size={10} /> : <CircleIcon size={10} />}
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: "6px",
|
||||
padding: "1px 0",
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: item.checked ? "var(--vscode-charts-green)" : "var(--vscode-descriptionForeground)",
|
||||
flexShrink: 0,
|
||||
marginTop: "1px",
|
||||
}}>
|
||||
{item.checked ? "✓" : "○"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs shrink-0 break-words",
|
||||
item.checked ? "text-description" : "text-badge-foreground",
|
||||
)}
|
||||
style={{
|
||||
color: item.checked ? "var(--vscode-descriptionForeground)" : "inherit",
|
||||
textDecoration: item.checked ? "line-through" : "none",
|
||||
opacity: item.checked ? 0.7 : 1,
|
||||
fontSize: "12px",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
lineHeight: "1.3",
|
||||
}}>
|
||||
{item.text}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cn, Tooltip } from "@heroui/react"
|
||||
import React, { useMemo } from "react"
|
||||
import { Tooltip } from "@heroui/react"
|
||||
import React from "react"
|
||||
|
||||
interface HeroTooltipProps {
|
||||
content: React.ReactNode
|
||||
@@ -8,8 +8,6 @@ interface HeroTooltipProps {
|
||||
delay?: number
|
||||
closeDelay?: number
|
||||
placement?: "top" | "bottom" | "left" | "right"
|
||||
showArrow?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,41 +18,39 @@ const HeroTooltip: React.FC<HeroTooltipProps> = ({
|
||||
content,
|
||||
children,
|
||||
className,
|
||||
showArrow = false,
|
||||
delay = 0,
|
||||
closeDelay = 500,
|
||||
placement = "top",
|
||||
disabled = false,
|
||||
}) => {
|
||||
// If content is a simple string, wrap it in the tailwind styled divs
|
||||
const formattedContent = useMemo(() => {
|
||||
return typeof content === "string" ? (
|
||||
const formattedContent =
|
||||
typeof content === "string" ? (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-code-background text-code-foreground border border-code-foreground/20 rounded shadow-md max-w-[250px] text-sm",
|
||||
className,
|
||||
"p-2",
|
||||
)}>
|
||||
<span className="whitespace-pre-wrap break-words overflow-y-auto">{content}</span>
|
||||
className={`bg-[var(--vscode-editor-background)] text-[var(--vscode-editor-foreground)]
|
||||
border border-[var(--vscode-widget-border)] rounded p-2 w-full shadow-md text-xs max-w-[250px] ${className}`}>
|
||||
<div
|
||||
className="whitespace-pre-wrap break-words max-h-[150px] overflow-y-auto text-[11px]
|
||||
font-[var(--vscode-editor-font-family)] p-1 rounded">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// If content is already a React node, assume it's pre-formatted
|
||||
content
|
||||
)
|
||||
}, [content, className])
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
classNames={{
|
||||
content: "hero-tooltip-content pointer-events-none", // Prevent hovering over tooltip
|
||||
}}
|
||||
closeDelay={closeDelay}
|
||||
closeDelay={0}
|
||||
content={formattedContent} // Immediate close when cursor moves away
|
||||
delay={delay}
|
||||
disableAnimation={true}
|
||||
isDisabled={disabled}
|
||||
isDisabled={false}
|
||||
placement={placement} // Disable animation for immediate appearance/disappearance
|
||||
showArrow={showArrow}>
|
||||
showArrow={false}>
|
||||
{children}
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
@@ -55,7 +55,6 @@ export interface ExtensionStateContextType extends ExtensionState {
|
||||
showAccount: boolean
|
||||
showAnnouncement: boolean
|
||||
showChatModelSelector: boolean
|
||||
expandTaskHeader: boolean
|
||||
|
||||
// Setters
|
||||
setDictationSettings: (value: DictationSettings) => void
|
||||
@@ -76,7 +75,6 @@ export interface ExtensionStateContextType extends ExtensionState {
|
||||
setGlobalWorkflowToggles: (toggles: Record<string, boolean>) => void
|
||||
setMcpMarketplaceCatalog: (value: McpMarketplaceCatalog) => void
|
||||
setTotalTasksSize: (value: number | null) => void
|
||||
setExpandTaskHeader: (value: boolean) => void
|
||||
|
||||
// Refresh functions
|
||||
refreshOpenRouterModels: () => void
|
||||
@@ -212,7 +210,6 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
yoloModeToggled: false,
|
||||
customPrompt: undefined,
|
||||
useAutoCondense: false,
|
||||
autoCondenseThreshold: undefined,
|
||||
favoritedModelIds: [],
|
||||
|
||||
// NEW: Add workspace information with defaults
|
||||
@@ -220,7 +217,6 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
primaryRootIndex: 0,
|
||||
isMultiRootWorkspace: false,
|
||||
})
|
||||
const [expandTaskHeader, setExpandTaskHeader] = useState(true)
|
||||
const [didHydrateState, setDidHydrateState] = useState(false)
|
||||
const [showWelcome, setShowWelcome] = useState(false)
|
||||
const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
|
||||
@@ -725,8 +721,6 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
refreshOpenRouterModels,
|
||||
onRelinquishControl,
|
||||
setUserInfo: (userInfo?: UserInfo) => setState((prevState) => ({ ...prevState, userInfo })),
|
||||
expandTaskHeader,
|
||||
setExpandTaskHeader,
|
||||
setDictationSettings: (value: DictationSettings) =>
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
|
||||
@@ -82,22 +82,14 @@ export default {
|
||||
foreground: "var(--vscode-banner-foreground)",
|
||||
icon: "var(--vscode-banner-iconForeground)",
|
||||
},
|
||||
toolbar: {
|
||||
DEFAULT: "var(--vscode-toolbar-background)",
|
||||
hover: "var(--vscode-toolbar-hoverBackground)",
|
||||
},
|
||||
error: "var(--vscode-errorForeground)",
|
||||
description: "var(--vscode-descriptionForeground)",
|
||||
success: "var(--vscode-charts-green)",
|
||||
warning: "var(--vscode-charts-yellow)",
|
||||
},
|
||||
fontSize: {
|
||||
xl: "calc(2 * var(--vscode-font-size))",
|
||||
lg: "calc(1.5 * var(--vscode-font-size))",
|
||||
md: "calc(1.25 * var(--vscode-font-size))",
|
||||
sm: "var(--vscode-font-size)",
|
||||
xs: "calc(0.85 * var(--vscode-font-size))",
|
||||
xxs: "calc(0.75 * var(--vscode-font-size))",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user