mirror of
https://github.com/cline/cline.git
synced 2026-09-06 20:41:02 +08:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fdb862654 | |||
| fe69e22067 | |||
| 1ebb7eb694 | |||
| 230e94b5f3 | |||
| 7368984343 | |||
| 87884775fa | |||
| 80968508d4 | |||
| 45fea9247d | |||
| 306941aedd | |||
| 6f420028d2 | |||
| 01568f7d18 | |||
| 2fc2b7d5e7 | |||
| c434fc0815 | |||
| 6cb6af2892 | |||
| 49d9a58e7b | |||
| 899ddda189 | |||
| ef1b32eaf5 | |||
| 4e549ae782 | |||
| 94a17964e0 | |||
| 5b183982dc | |||
| bab60a9df9 | |||
| b6abc9ee99 | |||
| 026136f24e | |||
| b57a1c4862 | |||
| 005c16118f | |||
| 909e0d15ce | |||
| c120d0dd66 | |||
| 126968bc83 | |||
| 57777ee7e0 | |||
| 9530601c46 | |||
| 22b0088219 | |||
| b35eb4dcfd | |||
| 6a7d89aecf | |||
| 324be60127 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Refactor Task Header UI with interactive context window management
|
||||
@@ -140,6 +140,7 @@ message UpdateSettingsRequest {
|
||||
optional BrowserSettingsUpdate browser_settings = 20;
|
||||
optional string default_terminal_profile = 21;
|
||||
optional bool yolo_mode_toggled = 22;
|
||||
optional int32 auto_condense_threshold = 23;
|
||||
}
|
||||
|
||||
// Complete API Configuration message
|
||||
|
||||
@@ -63,6 +63,7 @@ message TaskResponse {
|
||||
int32 tokens_out = 8;
|
||||
int32 cache_writes = 9;
|
||||
int32 cache_reads = 10;
|
||||
string model_id = 11;
|
||||
}
|
||||
|
||||
// Request for getting task history with filtering
|
||||
@@ -92,6 +93,7 @@ message TaskItem {
|
||||
int32 tokens_out = 8;
|
||||
int32 cache_writes = 9;
|
||||
int32 cache_reads = 10;
|
||||
string model_id = 11;
|
||||
}
|
||||
|
||||
// Request for ask response operation
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface ApiProviderInfo {
|
||||
providerId: string
|
||||
model: ApiHandlerModel
|
||||
customPrompt?: string // "compact"
|
||||
autoCondenseThreshold?: number // 0-1 range
|
||||
}
|
||||
|
||||
export interface SingleCompletionHandler {
|
||||
|
||||
@@ -108,15 +108,21 @@ export class ContextManager {
|
||||
/**
|
||||
* Determine whether we should compact context window, based on token counts
|
||||
*/
|
||||
shouldCompactContextWindow(clineMessages: ClineMessage[], api: ApiHandler, previousApiReqIndex: number): boolean {
|
||||
shouldCompactContextWindow(
|
||||
clineMessages: ClineMessage[],
|
||||
api: ApiHandler,
|
||||
previousApiReqIndex: number,
|
||||
thresholdPercentage?: number,
|
||||
): boolean {
|
||||
if (previousApiReqIndex >= 0) {
|
||||
const previousRequest = clineMessages[previousApiReqIndex]
|
||||
if (previousRequest && previousRequest.text) {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
|
||||
const { maxAllowedSize } = getContextWindowInfo(api)
|
||||
return totalTokens >= maxAllowedSize
|
||||
const { contextWindow, maxAllowedSize } = getContextWindowInfo(api)
|
||||
const thresholdTokens = thresholdPercentage ? Math.floor(contextWindow * thresholdPercentage) : maxAllowedSize
|
||||
return totalTokens >= thresholdTokens
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -749,6 +749,7 @@ export class Controller {
|
||||
const localWindsurfRulesToggles = this.stateManager.getWorkspaceStateKey("localWindsurfRulesToggles")
|
||||
const localCursorRulesToggles = this.stateManager.getWorkspaceStateKey("localCursorRulesToggles")
|
||||
const workflowToggles = this.stateManager.getWorkspaceStateKey("workflowToggles")
|
||||
const autoCondenseThreshold = this.stateManager.getGlobalSettingsKey("autoCondenseThreshold")
|
||||
|
||||
const currentTaskItem = this.task?.taskId ? (taskHistory || []).find((item) => item.id === this.task?.taskId) : undefined
|
||||
const clineMessages = this.task?.messageStateHandler.getClineMessages() || []
|
||||
@@ -805,6 +806,7 @@ export class Controller {
|
||||
taskHistory: processedTaskHistory,
|
||||
platform,
|
||||
shouldShowAnnouncement,
|
||||
autoCondenseThreshold,
|
||||
favoritedModelIds,
|
||||
// NEW: Add workspace information
|
||||
workspaceRoots: this.workspaceManager?.getRoots() ?? [],
|
||||
|
||||
@@ -288,6 +288,11 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
}
|
||||
}
|
||||
|
||||
if (request.autoCondenseThreshold !== undefined) {
|
||||
const threshold = Math.min(1, Math.max(0, request.autoCondenseThreshold)) // Clamp to 0-1 range
|
||||
controller.stateManager.setGlobalState("autoCondenseThreshold", threshold)
|
||||
}
|
||||
|
||||
// Post updated state to webview
|
||||
await controller.postStateToWebview()
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ export async function getTaskHistory(controller: Controller, request: GetTaskHis
|
||||
tokensOut: item.tokensOut || 0,
|
||||
cacheWrites: item.cacheWrites || 0,
|
||||
cacheReads: item.cacheReads || 0,
|
||||
modelId: item.modelId || "",
|
||||
}))
|
||||
|
||||
return TaskHistoryArray.create({
|
||||
|
||||
@@ -992,10 +992,9 @@ export class StateManager {
|
||||
planModeVercelAiGatewayModelInfo:
|
||||
this.taskStateCache["planModeVercelAiGatewayModelInfo"] ||
|
||||
this.globalStateCache["planModeVercelAiGatewayModelInfo"],
|
||||
planModeOcaModelId: this.globalStateCache["planModeOcaModelId"],
|
||||
planModeOcaModelId: this.globalStateCache["planModeOcaModelId"],
|
||||
planModeOcaModelInfo: this.globalStateCache["planModeOcaModelInfo"],
|
||||
|
||||
|
||||
// Act mode configurations
|
||||
actModeApiProvider: this.taskStateCache["actModeApiProvider"] || this.globalStateCache["actModeApiProvider"],
|
||||
actModeApiModelId: this.taskStateCache["actModeApiModelId"] || this.globalStateCache["actModeApiModelId"],
|
||||
@@ -1055,7 +1054,7 @@ export class StateManager {
|
||||
actModeVercelAiGatewayModelInfo:
|
||||
this.taskStateCache["actModeVercelAiGatewayModelInfo"] ||
|
||||
this.globalStateCache["actModeVercelAiGatewayModelInfo"],
|
||||
actModeOcaModelId: this.globalStateCache["actModeOcaModelId"],
|
||||
actModeOcaModelId: this.globalStateCache["actModeOcaModelId"],
|
||||
actModeOcaModelInfo: this.globalStateCache["actModeOcaModelInfo"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ export interface Settings {
|
||||
focusChainSettings: FocusChainSettings
|
||||
customPrompt: "compact" | undefined
|
||||
difyBaseUrl: string | undefined
|
||||
autoCondenseThreshold: number | undefined // number from 0 to 1
|
||||
ocaBaseUrl: string | undefined
|
||||
|
||||
// Plan mode configurations
|
||||
|
||||
@@ -235,6 +235,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",
|
||||
) as number | undefined // number from 0 to 1
|
||||
|
||||
// Get mode-related configurations
|
||||
const mode = context.globalState.get<GlobalStateAndSettings["mode"]>("mode")
|
||||
@@ -551,6 +554,7 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
|
||||
mcpMarketplaceCatalog,
|
||||
qwenCodeOauthPath,
|
||||
customPrompt,
|
||||
autoCondenseThreshold: autoCondenseThreshold || 75,
|
||||
// Multi-root workspace support
|
||||
workspaceRoots,
|
||||
primaryRootIndex: primaryRootIndex ?? 0,
|
||||
|
||||
@@ -1847,9 +1847,9 @@ export class Task {
|
||||
// checkpointManagerErrorMessage is already set and will be part of the state.
|
||||
// No explicit UI message here, error message will be in ExtensionState.
|
||||
}
|
||||
|
||||
const modelId = this.api.getModel().id
|
||||
// Separate logic when using the auto-condense context management vs the original context management methods
|
||||
if (this.useAutoCondense && isNextGenModelFamily(this.api.getModel().id)) {
|
||||
if (this.useAutoCondense && isNextGenModelFamily(modelId)) {
|
||||
// when we initially trigger the context cleanup, we will be increasing the context window size, so we need some state `currentlySummarizing`
|
||||
// to store whether we have already started the context summarization flow, so we don't attempt to summarize again. additionally, immediately
|
||||
// post summarizing we need to increment the conversationHistoryDeletedRange to mask out the summarization-trigger user & assistant response messaages
|
||||
@@ -1869,10 +1869,14 @@ export class Task {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const autoCondenseThreshold = this.stateManager.getGlobalSettingsKey("autoCondenseThreshold") as
|
||||
| number
|
||||
| undefined
|
||||
shouldCompact = this.contextManager.shouldCompactContextWindow(
|
||||
this.messageStateHandler.getClineMessages(),
|
||||
this.api,
|
||||
previousApiReqIndex,
|
||||
autoCondenseThreshold,
|
||||
)
|
||||
|
||||
// There is an edge case where the summarize_task tool call completes but the user cancels the next request before it finishes
|
||||
@@ -2026,9 +2030,9 @@ export class Task {
|
||||
cancelReason,
|
||||
streamingFailedMessage,
|
||||
})
|
||||
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
await this.messageStateHandler.saveClineMessagesAndUpdateHistory(modelId)
|
||||
|
||||
telemetryService.captureConversationTurnEvent(this.ulid, providerId, this.api.getModel().id, "assistant", {
|
||||
telemetryService.captureConversationTurnEvent(this.ulid, providerId, modelId, "assistant", {
|
||||
tokensIn: inputTokens,
|
||||
tokensOut: outputTokens,
|
||||
cacheWriteTokens,
|
||||
|
||||
@@ -64,7 +64,7 @@ export class MessageStateHandler {
|
||||
this.clineMessages = newMessages
|
||||
}
|
||||
|
||||
async saveClineMessagesAndUpdateHistory(): Promise<void> {
|
||||
async saveClineMessagesAndUpdateHistory(modelId?: string): Promise<void> {
|
||||
try {
|
||||
await saveClineMessages(this.context, this.taskId, this.clineMessages)
|
||||
|
||||
@@ -104,6 +104,7 @@ export class MessageStateHandler {
|
||||
conversationHistoryDeletedRange: this.taskState.conversationHistoryDeletedRange,
|
||||
isFavorited: this.taskIsFavorited,
|
||||
checkpointManagerErrorMessage: this.taskState.checkpointManagerErrorMessage,
|
||||
modelId,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to save cline messages:", error)
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface ExtensionState {
|
||||
useAutoCondense?: boolean
|
||||
focusChainSettings: FocusChainSettings
|
||||
customPrompt?: string
|
||||
autoCondenseThreshold?: number
|
||||
favoritedModelIds: string[]
|
||||
// NEW: Add workspace information
|
||||
workspaceRoots: WorkspaceRoot[]
|
||||
|
||||
@@ -15,4 +15,7 @@ export type HistoryItem = {
|
||||
conversationHistoryDeletedRange?: [number, number]
|
||||
isFavorited?: boolean
|
||||
checkpointManagerErrorMessage?: string
|
||||
|
||||
// The last model ID used for this task
|
||||
modelId?: string
|
||||
}
|
||||
|
||||
@@ -28,6 +28,14 @@ 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 = {
|
||||
@@ -60,6 +68,13 @@ 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
|
||||
|
||||
Generated
+14372
-14372
File diff suppressed because it is too large
Load Diff
@@ -153,8 +153,8 @@ const createApiReqMessage = (minutesAgo: number, request: string, metrics: any =
|
||||
"api_req_started",
|
||||
JSON.stringify({
|
||||
request,
|
||||
tokensIn: 850,
|
||||
tokensOut: 420,
|
||||
tokensIn: 19500,
|
||||
tokensOut: 4220,
|
||||
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: 1200, tokensOut: 680, cost: 0.042 }),
|
||||
createApiReqMessage(4.2, "Component creation request", { tokensIn: 12020, tokensOut: 6180, cost: 0.042 }),
|
||||
createMessage(
|
||||
4,
|
||||
"say",
|
||||
@@ -190,7 +190,7 @@ const mockActiveMessages: ClineMessage[] = [
|
||||
content: "// Navigation component code...",
|
||||
}),
|
||||
),
|
||||
createApiReqMessage(3.5, "Final response request", { tokensIn: 450, tokensOut: 320, cost: 0.018 }),
|
||||
createApiReqMessage(3.5, "Final response request", { tokensIn: 41550, tokensOut: 3320, cost: 0.018 }),
|
||||
createMessage(
|
||||
3.3,
|
||||
"say",
|
||||
@@ -213,6 +213,8 @@ const mockStreamingMessages: ClineMessage[] = [
|
||||
// Reusable state and decorator factories
|
||||
const createMockState = (overrides: any = {}) => ({
|
||||
...useExtensionState(),
|
||||
useAutoCondense: true,
|
||||
autoCondenseThreshold: 0.5,
|
||||
welcomeViewCompleted: true,
|
||||
showWelcome: false,
|
||||
clineMessages: mockActiveMessages,
|
||||
@@ -414,14 +416,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: 1800, tokensOut: 950, cost: 0.065 }),
|
||||
createApiReqMessage(4.9, "Planning analysis request", { tokensIn: 20000, tokensOut: 19500, 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: 2200, tokensOut: 1400, cost: 0.095 }),
|
||||
createApiReqMessage(4.5, "Detailed planning request", { tokensIn: 20002, tokensOut: 12500, 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/TaskHeader"
|
||||
import { highlightText } from "./task-header/Highlights"
|
||||
|
||||
interface UserMessageProps {
|
||||
text?: string
|
||||
|
||||
@@ -44,6 +44,7 @@ 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}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { useMemo } from "react"
|
||||
|
||||
export const AutoCondenseMarker: React.FC<{ threshold: number; usage: number; isContextWindowHoverOpen?: boolean }> = ({
|
||||
threshold,
|
||||
usage,
|
||||
isContextWindowHoverOpen,
|
||||
}) => {
|
||||
// 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
|
||||
return {
|
||||
start: _threshold + "%",
|
||||
label: _threshold.toFixed(0),
|
||||
end: usage >= threshold * 100 ? usage - _threshold + "%" : undefined,
|
||||
}
|
||||
}, [threshold, usage, isContextWindowHoverOpen])
|
||||
|
||||
if (!threshold) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1" id="auto-condense-threshold-marker">
|
||||
<div
|
||||
className="absolute top-0 bottom-0 h-full cursor-pointer pointer-events-none z-10 bg-button-background shadow-lg outline-button-background/80 outline-0.5 w-1.5"
|
||||
style={{ left: marker.start }}>
|
||||
{isContextWindowHoverOpen && (
|
||||
<div className="absolute -top-4 -left-1 text-button-background/80">{marker.label}%</div>
|
||||
)}
|
||||
{marker.end !== undefined && (
|
||||
<div
|
||||
className="fixed top-0 bottom-0 h-full z-20 bg-black/45"
|
||||
style={{ left: marker.start, width: marker.end }}></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
AutoCondenseMarker.displayName = "AutoCondenseMarker"
|
||||
@@ -0,0 +1,66 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { cn, Progress, Tooltip } from "@heroui/react"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import debounce from "lodash/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 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)
|
||||
}, [])
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setIsOpened(false)
|
||||
}, [])
|
||||
|
||||
// Close tooltip when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (progressBarRef.current && !progressBarRef.current.contains(event.target as Node)) {
|
||||
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={2000}
|
||||
content={
|
||||
<ContextWindowSummary
|
||||
autoCompactThreshold={threshold}
|
||||
cacheReads={cacheReads}
|
||||
cacheWrites={cacheWrites}
|
||||
contextWindow={tokenData.max}
|
||||
percentage={tokenData.percentage}
|
||||
tokensIn={tokensIn}
|
||||
tokensOut={tokensOut}
|
||||
tokenUsed={tokenData.used}
|
||||
/>
|
||||
}
|
||||
isOpen={isOpened}
|
||||
offset={-2}
|
||||
placement="bottom"
|
||||
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"
|
||||
onBlur={handleBlur}
|
||||
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"
|
||||
onClick={handleContextWindowBarClick}
|
||||
size="md"
|
||||
value={tokenData.percentage}
|
||||
/>
|
||||
{useAutoCondense && (
|
||||
<AutoCondenseMarker
|
||||
isContextWindowHoverOpen={isOpened}
|
||||
key={threshold}
|
||||
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)
|
||||
@@ -0,0 +1,157 @@
|
||||
import { cn } from "@heroui/react"
|
||||
import React, { memo, useEffect, useMemo, useState } from "react"
|
||||
import HeroTooltip from "@/components/common/HeroTooltip"
|
||||
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
|
||||
}
|
||||
|
||||
const InfoRow = memo<{
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
tooltip?: string
|
||||
labelTooltip?: string
|
||||
placement: "top" | "bottom" | "left" | "right"
|
||||
}>(({ label, value, tooltip, labelTooltip, placement }) => (
|
||||
<div className="flex justify-between gap-3">
|
||||
{labelTooltip ? (
|
||||
<HeroTooltip content={labelTooltip} placement={placement}>
|
||||
<div className="font-semibold">{label}</div>
|
||||
</HeroTooltip>
|
||||
) : (
|
||||
<div className="font-semibold">{label}</div>
|
||||
)}
|
||||
<div className="text-muted-foreground cursor-auto">
|
||||
{tooltip ? (
|
||||
<HeroTooltip content={tooltip} placement={placement}>
|
||||
<span>{value}</span>
|
||||
</HeroTooltip>
|
||||
) : (
|
||||
<span>{value}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
InfoRow.displayName = "InfoRow"
|
||||
|
||||
// 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 TokenUsageInfo = 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])
|
||||
|
||||
const TokenDetailItem = memo<TokenDetail>(({ title, value, icon }) => (
|
||||
<HeroTooltip content={title} key={`${icon}-${value}`} placement="bottom">
|
||||
<span className="flex items-center gap-0.5 text-muted-foreground">
|
||||
<i className={`codicon ${icon} font-semibold `} />
|
||||
{value ? formatTokenNumber(value) : "--"}
|
||||
</span>
|
||||
</HeroTooltip>
|
||||
))
|
||||
TokenDetailItem.displayName = "TokenDetailItem"
|
||||
|
||||
if (!tokensIn) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between flex-wrap">
|
||||
<div className="font-semibold mr-1">Token Usage</div>
|
||||
<div className="flex items-center justify-between flex-wrap gap-1 opacity-80">
|
||||
{contextTokenDetails.map((item) => (
|
||||
<TokenDetailItem key={item.icon} {...item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
TokenUsageInfo.displayName = "TokenUsageInfo"
|
||||
|
||||
export const ContextWindowSummary: React.FC<TaskContextWindowButtonsProps> = ({
|
||||
contextWindow,
|
||||
tokenUsed,
|
||||
tokensIn,
|
||||
tokensOut,
|
||||
cacheWrites,
|
||||
cacheReads,
|
||||
percentage,
|
||||
autoCompactThreshold = 0,
|
||||
}) => {
|
||||
const [thresholdDisplay, setThresholdDisplay] = useState(autoCompactThreshold)
|
||||
const [isThresholdChanged, setIsThresholdChanged] = useState<"up" | "down" | undefined>(undefined)
|
||||
const [isThresholdFadingOut, setIsThresholdFadingOut] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (autoCompactThreshold !== thresholdDisplay) {
|
||||
const type = autoCompactThreshold > thresholdDisplay ? "up" : "down"
|
||||
setIsThresholdChanged(type)
|
||||
setThresholdDisplay(autoCompactThreshold)
|
||||
return () => {
|
||||
setTimeout(() => {
|
||||
setIsThresholdFadingOut(true)
|
||||
setTimeout(() => {
|
||||
setIsThresholdChanged(undefined)
|
||||
setIsThresholdFadingOut(false)
|
||||
}, 1000) // Duration of fade-out effect
|
||||
}, 2000) // Duration to show the changed value before starting fade-out
|
||||
}
|
||||
}
|
||||
}, [autoCompactThreshold, thresholdDisplay])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5 bg-menu rounded shadow-sm border border-menu-border z-100 max-w-xs p-4">
|
||||
{thresholdDisplay > 0 && (
|
||||
<InfoRow
|
||||
key={thresholdDisplay}
|
||||
label="Auto Condense Threshold"
|
||||
labelTooltip="When the context window usage exceeds current threshold, the task will be automatically condensed."
|
||||
placement="right"
|
||||
tooltip="Click on the context window bar to set a new auto condense threshold."
|
||||
value={
|
||||
<span
|
||||
className={cn({
|
||||
"transition-all": !isThresholdChanged && !isThresholdFadingOut,
|
||||
"text-success/50 transition-discrete": isThresholdChanged === "up" && !isThresholdFadingOut,
|
||||
"text-error/50 transition-discrete": isThresholdChanged === "down" && !isThresholdFadingOut,
|
||||
"text-muted-foreground transition-all": isThresholdFadingOut,
|
||||
})}>{`${(thresholdDisplay * 100).toFixed(0)}%`}</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<InfoRow
|
||||
label="Context Window"
|
||||
placement="bottom"
|
||||
tooltip={`${tokenUsed} of ${contextWindow}`}
|
||||
value={percentage ? `${percentage.toFixed(2)}% used` : contextWindow}
|
||||
/>
|
||||
<TokenUsageInfo cacheReads={cacheReads} cacheWrites={cacheWrites} tokensIn={tokensIn} tokensOut={tokensOut} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
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"
|
||||
@@ -0,0 +1,76 @@
|
||||
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 = "13px"
|
||||
const TIMELINE_HEIGHT = "12px"
|
||||
const BLOCK_WIDTH = "13px"
|
||||
const BLOCK_GAP = "3px"
|
||||
const BLOCK_GAP = "4px"
|
||||
const _TOOLTIP_MARGIN = 32 // 32px margin on each side
|
||||
|
||||
interface TaskTimelineProps {
|
||||
@@ -21,6 +21,7 @@ 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) {
|
||||
@@ -103,10 +104,22 @@ 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%",
|
||||
@@ -114,12 +127,14 @@ 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],
|
||||
[taskTimelinePropsMessages, messageIndexMap, onBlockClick, hoveredIndex],
|
||||
)
|
||||
|
||||
// Scroll to the end when messages change
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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 Condense</kbd> in the settings and set the auto-condense 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,13 +1,16 @@
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useState } from "react"
|
||||
import { Button } from "@heroui/button"
|
||||
import { cn } from "@heroui/react"
|
||||
import { CheckIcon, CopyIcon } from "lucide-react"
|
||||
import { useCallback, useState } from "react"
|
||||
import HeroTooltip from "@/components/common/HeroTooltip"
|
||||
|
||||
const CopyTaskButton: React.FC<{
|
||||
taskText?: string
|
||||
}> = ({ taskText }) => {
|
||||
className?: string
|
||||
}> = ({ taskText, className }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = () => {
|
||||
const handleCopy = useCallback(() => {
|
||||
if (!taskText) {
|
||||
return
|
||||
}
|
||||
@@ -16,20 +19,19 @@ const CopyTaskButton: React.FC<{
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
})
|
||||
}
|
||||
}, [taskText])
|
||||
|
||||
return (
|
||||
<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 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
import { Button, cn } from "@heroui/react"
|
||||
import { StringArrayRequest } from "@shared/proto/cline/common"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { TrashIcon } from "lucide-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, 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>
|
||||
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>
|
||||
</HeroTooltip>
|
||||
)
|
||||
DeleteTaskButton.displayName = "DeleteTaskButton"
|
||||
|
||||
export default DeleteTaskButton
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Button, cn } from "@heroui/react"
|
||||
import { NewTaskRequest } from "@shared/proto/cline/task"
|
||||
import { GitCompareIcon } from "lucide-react"
|
||||
import { TaskServiceClient } from "@/services/grpc-client"
|
||||
|
||||
const ForkTaskButton: React.FC<{
|
||||
text?: string
|
||||
images?: string[]
|
||||
files?: string[]
|
||||
className?: string
|
||||
}> = ({ text, images = [], files = [], className }) => {
|
||||
return (
|
||||
<Button
|
||||
aria-label="Fork Task"
|
||||
className={cn("flex items-center border-0 text-sm font-bold bg-transparent hover:opacity-100", className)}
|
||||
disabled={!text?.trim()}
|
||||
isIconOnly={true}
|
||||
onPress={() =>
|
||||
text &&
|
||||
TaskServiceClient.newTask(
|
||||
NewTaskRequest.create({
|
||||
text: text.trim(),
|
||||
images,
|
||||
files,
|
||||
}),
|
||||
)
|
||||
}
|
||||
radius="sm"
|
||||
size="sm"
|
||||
title="Fork Task">
|
||||
<GitCompareIcon size="14" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ForkTaskButton
|
||||
@@ -0,0 +1,30 @@
|
||||
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,11 +1,12 @@
|
||||
import { Button, cn } from "@heroui/react"
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import HeroTooltip from "@/components/common/HeroTooltip"
|
||||
import { ArrowDownToLineIcon } from "lucide-react"
|
||||
import { FileServiceClient } from "@/services/grpc-client"
|
||||
|
||||
const OpenDiskTaskHistoryButton: React.FC<{
|
||||
taskId?: string
|
||||
}> = ({ taskId }) => {
|
||||
className?: string
|
||||
}> = ({ taskId, className }) => {
|
||||
const handleOpenDiskTaskHistory = () => {
|
||||
if (!taskId) {
|
||||
return
|
||||
@@ -17,18 +18,16 @@ const OpenDiskTaskHistoryButton: React.FC<{
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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 {
|
||||
@@ -102,31 +104,18 @@ const ChecklistRenderer: React.FC<ChecklistRendererProps> = ({ text }) => {
|
||||
overflowY: items.length >= 10 ? "auto" : "visible",
|
||||
}}>
|
||||
{items.map((item, index) => (
|
||||
<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 ? "✓" : "○"}
|
||||
// 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} />}
|
||||
</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 { Tooltip } from "@heroui/react"
|
||||
import React from "react"
|
||||
import { cn, Tooltip } from "@heroui/react"
|
||||
import React, { useMemo } from "react"
|
||||
|
||||
interface HeroTooltipProps {
|
||||
content: React.ReactNode
|
||||
@@ -8,6 +8,8 @@ interface HeroTooltipProps {
|
||||
delay?: number
|
||||
closeDelay?: number
|
||||
placement?: "top" | "bottom" | "left" | "right"
|
||||
showArrow?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,39 +20,41 @@ 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 =
|
||||
typeof content === "string" ? (
|
||||
const formattedContent = useMemo(() => {
|
||||
return typeof content === "string" ? (
|
||||
<div
|
||||
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>
|
||||
className={cn(
|
||||
"bg-code-background text-code-foreground border-1 rounded shadow-md max-w-[250px] text-sm",
|
||||
className,
|
||||
"p-2",
|
||||
)}>
|
||||
<span className="whitespace-pre-wrap break-words overflow-y-auto">{content}</span>
|
||||
</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={0}
|
||||
closeDelay={closeDelay}
|
||||
content={formattedContent} // Immediate close when cursor moves away
|
||||
delay={delay}
|
||||
disableAnimation={true}
|
||||
isDisabled={false}
|
||||
isDisabled={disabled}
|
||||
placement={placement} // Disable animation for immediate appearance/disappearance
|
||||
showArrow={false}>
|
||||
showArrow={showArrow}>
|
||||
{children}
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { cn } from "@heroui/react"
|
||||
import { BooleanRequest, EmptyRequest, StringArrayRequest, StringRequest } from "@shared/proto/cline/common"
|
||||
import { GetTaskHistoryRequest, TaskFavoriteRequest } from "@shared/proto/cline/task"
|
||||
import { VSCodeButton, VSCodeCheckbox, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import { VSCodeButton, VSCodeCheckbox, VSCodeDropdown, VSCodeOption, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import Fuse, { FuseResult } from "fuse.js"
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowDownToLineIcon,
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
ArrowUpIcon,
|
||||
BrainIcon,
|
||||
FilterIcon,
|
||||
HardDriveIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Virtuoso } from "react-virtuoso"
|
||||
import DangerButton from "@/components/common/DangerButton"
|
||||
@@ -275,466 +287,257 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
[taskHistorySearchResults],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
.history-item:hover {
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
.delete-button, .export-button {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.history-item:hover .delete-button,
|
||||
.history-item:hover .export-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.history-item-highlight {
|
||||
background-color: var(--vscode-editor-findMatchHighlightBackground);
|
||||
color: inherit;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "10px 17px 10px 20px",
|
||||
}}>
|
||||
<h3
|
||||
style={{
|
||||
color: "var(--vscode-foreground)",
|
||||
margin: 0,
|
||||
}}>
|
||||
History
|
||||
</h3>
|
||||
<VSCodeButton onClick={() => onDone()}>Done</VSCodeButton>
|
||||
</div>
|
||||
<div style={{ padding: "5px 17px 6px 17px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "6px",
|
||||
}}>
|
||||
<VSCodeTextField
|
||||
onInput={(e) => {
|
||||
const newValue = (e.target as HTMLInputElement)?.value
|
||||
setSearchQuery(newValue)
|
||||
if (newValue && !searchQuery && sortOption !== "mostRelevant") {
|
||||
setLastNonRelevantSort(sortOption)
|
||||
setSortOption("mostRelevant")
|
||||
}
|
||||
}}
|
||||
placeholder="Fuzzy search history..."
|
||||
style={{ width: "100%" }}
|
||||
value={searchQuery}>
|
||||
<div
|
||||
className="codicon codicon-search"
|
||||
slot="start"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
marginTop: 2.5,
|
||||
opacity: 0.8,
|
||||
}}></div>
|
||||
{searchQuery && (
|
||||
<div
|
||||
aria-label="Clear search"
|
||||
className="input-icon-button codicon codicon-close"
|
||||
onClick={() => setSearchQuery("")}
|
||||
slot="end"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</VSCodeTextField>
|
||||
<VSCodeRadioGroup
|
||||
onChange={(e) => setSortOption((e.target as HTMLInputElement).value as SortOption)}
|
||||
style={{ display: "flex", flexWrap: "wrap" }}
|
||||
value={sortOption}>
|
||||
<VSCodeRadio value="newest">Newest</VSCodeRadio>
|
||||
<VSCodeRadio value="oldest">Oldest</VSCodeRadio>
|
||||
<VSCodeRadio value="mostExpensive">Most Expensive</VSCodeRadio>
|
||||
<VSCodeRadio value="mostTokens">Most Tokens</VSCodeRadio>
|
||||
<VSCodeRadio disabled={!searchQuery} style={{ opacity: searchQuery ? 1 : 0.5 }} value="mostRelevant">
|
||||
Most Relevant
|
||||
</VSCodeRadio>
|
||||
<CustomFilterRadio
|
||||
checked={showCurrentWorkspaceOnly}
|
||||
icon="workspace"
|
||||
label="Workspace"
|
||||
onChange={() => setShowCurrentWorkspaceOnly(!showCurrentWorkspaceOnly)}
|
||||
/>
|
||||
<CustomFilterRadio
|
||||
checked={showFavoritesOnly}
|
||||
icon="star-full"
|
||||
label="Favorites"
|
||||
onChange={() => setShowFavoritesOnly(!showFavoritesOnly)}
|
||||
/>
|
||||
</VSCodeRadioGroup>
|
||||
// const overview = useMemo(() => {
|
||||
// return {
|
||||
// totalTasks: tasks.length,
|
||||
// totalTokens: tasks.reduce((sum, task) => sum + (task.tokensIn || 0) + (task.tokensOut || 0), 0),
|
||||
// totalCost: tasks.reduce((sum, task) => sum + (task.totalCost || 0), 0),
|
||||
// totalTasksSize: totalTasksSize || 0,
|
||||
// totalFavorites: tasks.filter((task) => task.isFavorited).length,
|
||||
// }
|
||||
// }, [tasks.length, totalTasksSize])
|
||||
|
||||
<div className="flex justify-end gap-2.5">
|
||||
<VSCodeButton onClick={() => handleBatchHistorySelect(true)}>Select All</VSCodeButton>
|
||||
<VSCodeButton onClick={() => handleBatchHistorySelect(false)}>Select None</VSCodeButton>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden w-full h-full gap-3">
|
||||
<div className="flex justify-between items-center px-5 mt-3">
|
||||
<h3 className="text-foreground m-0">History</h3>
|
||||
<VSCodeButton onClick={() => onDone()}>Close</VSCodeButton>
|
||||
</div>
|
||||
<div className="px-5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<VSCodeTextField
|
||||
onInput={(e) => {
|
||||
const newValue = (e.target as HTMLInputElement)?.value
|
||||
setSearchQuery(newValue)
|
||||
if (newValue && !searchQuery && sortOption !== "mostRelevant") {
|
||||
setLastNonRelevantSort(sortOption)
|
||||
setSortOption("mostRelevant")
|
||||
}
|
||||
}}
|
||||
placeholder="Fuzzy search history..."
|
||||
style={{ width: "100%" }}
|
||||
value={searchQuery}>
|
||||
<div className="codicon codicon-search opacity-80" slot="start" />
|
||||
{searchQuery && (
|
||||
<div
|
||||
aria-label="Clear search"
|
||||
className="input-icon-button codicon codicon-close"
|
||||
onClick={() => setSearchQuery("")}
|
||||
slot="end"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</VSCodeTextField>
|
||||
<div className="flex gap-2 items-center">
|
||||
<FilterIcon size={14} />
|
||||
<VSCodeDropdown
|
||||
className="flex-1/2"
|
||||
name="sort-by"
|
||||
onChange={(e) => setSortOption((e.target as HTMLInputElement).value as SortOption)}
|
||||
value={sortOption}>
|
||||
<VSCodeOption value="newest">Newest</VSCodeOption>
|
||||
<VSCodeOption value="oldest">Oldest</VSCodeOption>
|
||||
<VSCodeOption value="mostExpensive">Most Expensive</VSCodeOption>
|
||||
<VSCodeOption value="mostTokens">Most Tokens</VSCodeOption>
|
||||
<VSCodeOption disabled={!searchQuery} style={{ opacity: searchQuery ? 1 : 0.5 }} value="mostRelevant">
|
||||
Most Relevant
|
||||
</VSCodeOption>
|
||||
</VSCodeDropdown>
|
||||
<CustomFilterRadio
|
||||
checked={showCurrentWorkspaceOnly}
|
||||
icon="workspace"
|
||||
label="Workspace"
|
||||
onChange={() => setShowCurrentWorkspaceOnly(!showCurrentWorkspaceOnly)}
|
||||
/>
|
||||
<CustomFilterRadio
|
||||
checked={showFavoritesOnly}
|
||||
icon="star-full"
|
||||
label="Favorites"
|
||||
onChange={() => setShowFavoritesOnly(!showFavoritesOnly)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flexGrow: 1, overflowY: "auto", margin: 0 }}>
|
||||
<Virtuoso
|
||||
data={taskHistorySearchResults}
|
||||
itemContent={(index, item) => (
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<Virtuoso
|
||||
data={taskHistorySearchResults}
|
||||
itemContent={(_, item) => (
|
||||
<div
|
||||
className="w-full flex shrink-0 cursor-pointer border-b border-muted-foreground/30 *:last:border-0 hover:bg-muted/40"
|
||||
key={item.id}>
|
||||
<div
|
||||
className="history-item"
|
||||
key={item.id}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderBottom:
|
||||
index < taskHistory.length - 1 ? "1px solid var(--vscode-panel-border)" : "none",
|
||||
display: "flex",
|
||||
}}>
|
||||
<VSCodeCheckbox
|
||||
checked={selectedItems.includes(item.id)}
|
||||
className="pl-3 pr-1 py-auto"
|
||||
onClick={(e) => {
|
||||
const checked = (e.target as HTMLInputElement).checked
|
||||
handleHistorySelect(item.id, checked)
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
onClick={() => handleShowTaskWithId(item.id)}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
padding: "12px 20px",
|
||||
paddingLeft: "16px",
|
||||
position: "relative",
|
||||
flexGrow: 1,
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
fontWeight: 500,
|
||||
fontSize: "0.85em",
|
||||
textTransform: "uppercase",
|
||||
}}>
|
||||
className="flex flex-col gap-2 relative flex-grow mx-3 my-1 py-2 px-1"
|
||||
onClick={() => handleShowTaskWithId(item.id)}>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<VSCodeCheckbox
|
||||
checked={selectedItems.includes(item.id)}
|
||||
className="text-xs"
|
||||
onClick={(e) => {
|
||||
const checked = (e.target as HTMLInputElement).checked
|
||||
handleHistorySelect(item.id, checked)
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
<span className="text-description text-xs font-medium capitalize">
|
||||
{formatDate(item.ts)}
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "4px" }}>
|
||||
{/* only show delete button if task not favorited */}
|
||||
{!(pendingFavoriteToggles[item.id] ?? item.isFavorited) && (
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
aria-label="Delete"
|
||||
className="delete-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteHistoryItem(item.id)
|
||||
}}
|
||||
style={{ padding: "0px 0px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "3px",
|
||||
fontSize: "11px",
|
||||
}}>
|
||||
<span className="codicon codicon-trash"></span>
|
||||
{formatSize(item.size)}
|
||||
</div>
|
||||
</VSCodeButton>
|
||||
)}
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
aria-label={item.isFavorited ? "Remove from favorites" : "Add to favorites"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleFavorite(item.id, item.isFavorited || false)
|
||||
}}
|
||||
style={{ padding: "0px" }}>
|
||||
<div
|
||||
className={`codicon ${
|
||||
pendingFavoriteToggles[item.id] !== undefined
|
||||
? pendingFavoriteToggles[item.id]
|
||||
? "codicon-star-full"
|
||||
: "codicon-star-empty"
|
||||
: item.isFavorited
|
||||
? "codicon-star-full"
|
||||
: "codicon-star-empty"
|
||||
}`}
|
||||
style={{
|
||||
color:
|
||||
(pendingFavoriteToggles[item.id] ?? item.isFavorited)
|
||||
? "var(--vscode-button-background)"
|
||||
: "inherit",
|
||||
opacity: (pendingFavoriteToggles[item.id] ?? item.isFavorited) ? 1 : 0.7,
|
||||
display:
|
||||
(pendingFavoriteToggles[item.id] ?? item.isFavorited)
|
||||
? "block"
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "8px", position: "relative" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "var(--vscode-font-size)",
|
||||
color: "var(--vscode-foreground)",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
<div className="flex gap-0.5 items-center">
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
aria-label="Delete"
|
||||
className="text-description text-xs "
|
||||
disabled={pendingFavoriteToggles[item.id] ?? item.isFavorited}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteHistoryItem(item.id)
|
||||
}}>
|
||||
<span
|
||||
className="ph-no-capture"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: item.task,
|
||||
}}
|
||||
<Trash2Icon size={12} />
|
||||
</VSCodeButton>
|
||||
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
aria-label="Export"
|
||||
className="text-description text-xs "
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
TaskServiceClient.exportTaskWithId(
|
||||
StringRequest.create({ value: item.id }),
|
||||
).catch((err) => console.error("Failed to export task:", err))
|
||||
}}>
|
||||
<ArrowDownToLineIcon size={12} />
|
||||
</VSCodeButton>
|
||||
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
aria-label={item.isFavorited ? "Remove from favorites" : "Add to favorites"}
|
||||
className="text-xs "
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleFavorite(item.id, !!item.isFavorited)
|
||||
}}>
|
||||
<div
|
||||
className={cn({
|
||||
"codicon codicon-star-full text-button-background block":
|
||||
pendingFavoriteToggles[item.id] ?? item.isFavorited,
|
||||
"codicon codicon-star-empty": !(
|
||||
pendingFavoriteToggles[item.id] ?? item.isFavorited
|
||||
),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
flexWrap: "wrap",
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
Tokens:
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "3px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
<i
|
||||
className="codicon codicon-arrow-up"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "-2px",
|
||||
}}
|
||||
/>
|
||||
{formatLargeNumber(item.tokensIn || 0)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "3px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
<i
|
||||
className="codicon codicon-arrow-down"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "-2px",
|
||||
}}
|
||||
/>
|
||||
{formatLargeNumber(item.tokensOut || 0)}
|
||||
</span>
|
||||
</div>
|
||||
{!item.totalCost && <ExportButton itemId={item.id} />}
|
||||
</div>
|
||||
|
||||
<div className="relative text-sm text-foreground overflow-hidden whitespace-pre-wrap wrap-anywhere max-h-13 py-1">
|
||||
<span
|
||||
className="text-xs text-foreground"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: item.task,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{item.modelId && (
|
||||
<div className="flex gap-1 items-center text-xs text-description">
|
||||
<BrainIcon size={12} />
|
||||
{item.modelId}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 text-description flex-wrap justify-between">
|
||||
<div className="flex gap-2 text-description flex-wrap text-xs">
|
||||
{item.totalCost > 0 && (
|
||||
<div className="flex gap-1 items-center text-xs">${item.totalCost?.toFixed(4)}</div>
|
||||
)}
|
||||
<div className="flex gap-0.5 items-center">
|
||||
<span>Tokens</span>
|
||||
<span className="inline-flex items-center">
|
||||
<ArrowUpIcon size={12} />
|
||||
{formatLargeNumber(item.tokensIn || 0)}
|
||||
</span>
|
||||
<span className="inline-flex items-center">
|
||||
<ArrowDownIcon size={12} />
|
||||
{formatLargeNumber(item.tokensOut || 0)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!!(item.cacheWrites || item.cacheReads) && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
flexWrap: "wrap",
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
Cache:
|
||||
</span>
|
||||
{item.cacheWrites + item.cacheReads > 0 && (
|
||||
<div className="flex gap-0.5 items-center">
|
||||
<span>Cache</span>
|
||||
{item.cacheWrites > 0 && (
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "3px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
<i
|
||||
className="codicon codicon-arrow-right"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "-1px",
|
||||
}}
|
||||
/>
|
||||
<span className="inline-flex items-center">
|
||||
<ArrowRightIcon size={12} />
|
||||
{formatLargeNumber(item.cacheWrites)}
|
||||
</span>
|
||||
)}
|
||||
{item.cacheReads > 0 && (
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "3px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
<i
|
||||
className="codicon codicon-arrow-left"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: 0,
|
||||
}}
|
||||
/>
|
||||
<span className="inline-flex items-center">
|
||||
<ArrowLeftIcon size={12} />
|
||||
{formatLargeNumber(item.cacheReads)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!!item.totalCost && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginTop: -2,
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
API Cost:
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
${item.totalCost?.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
<ExportButton itemId={item.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-xs text-description">
|
||||
<HardDriveIcon className="px-0.5" size={12} />
|
||||
{formatSize(item.size)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
overflowY: "scroll",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 10px",
|
||||
borderTop: "1px solid var(--vscode-panel-border)",
|
||||
}}>
|
||||
{selectedItems.length > 0 ? (
|
||||
<DangerButton
|
||||
aria-label="Delete selected items"
|
||||
onClick={() => {
|
||||
handleDeleteSelectedHistoryItems(selectedItems)
|
||||
}}
|
||||
style={{ width: "100%" }}>
|
||||
Delete {selectedItems.length > 1 ? selectedItems.length : ""} Selected
|
||||
{selectedItemsSize > 0 ? ` (${formatSize(selectedItemsSize)})` : ""}
|
||||
</DangerButton>
|
||||
) : (
|
||||
<DangerButton
|
||||
aria-label="Delete all history"
|
||||
disabled={deleteAllDisabled || taskHistory.length === 0}
|
||||
onClick={() => {
|
||||
setDeleteAllDisabled(true)
|
||||
TaskServiceClient.deleteAllTaskHistory(BooleanRequest.create({}))
|
||||
.then(() => fetchTotalTasksSize())
|
||||
.catch((error) => console.error("Error deleting task history:", error))
|
||||
.finally(() => setDeleteAllDisabled(false))
|
||||
}}
|
||||
style={{ width: "100%" }}>
|
||||
Delete All History{totalTasksSize !== null ? ` (${formatSize(totalTasksSize)})` : ""}
|
||||
</DangerButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
overflowY: "scroll",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<div className="flex p-1 gap-2 border-t border-muted-foreground/20">
|
||||
<VSCodeButton
|
||||
className="flex-1/2"
|
||||
onClick={() => handleBatchHistorySelect(selectedItems.length !== taskHistorySearchResults.length)}>
|
||||
{selectedItems.length === taskHistorySearchResults.length ? "Deselect All" : "Select All"}
|
||||
</VSCodeButton>
|
||||
{selectedItems.length > 0 ? (
|
||||
<DangerButton
|
||||
aria-label="Delete selected items"
|
||||
className="flex-1/2"
|
||||
onClick={() => {
|
||||
handleDeleteSelectedHistoryItems(selectedItems)
|
||||
}}>
|
||||
Delete {selectedItems.length > 1 ? selectedItems.length : ""} Selected
|
||||
{selectedItemsSize > 0 ? ` (${formatSize(selectedItemsSize)})` : ""}
|
||||
</DangerButton>
|
||||
) : (
|
||||
<DangerButton
|
||||
aria-label="Delete all history"
|
||||
className="flex-1/2"
|
||||
disabled={deleteAllDisabled || taskHistory.length === 0}
|
||||
onClick={() => {
|
||||
setDeleteAllDisabled(true)
|
||||
TaskServiceClient.deleteAllTaskHistory(BooleanRequest.create({}))
|
||||
.then(() => fetchTotalTasksSize())
|
||||
.catch((error) => console.error("Error deleting task history:", error))
|
||||
.finally(() => setDeleteAllDisabled(false))
|
||||
}}>
|
||||
Delete All History{totalTasksSize !== null ? ` (${formatSize(totalTasksSize)})` : ""}
|
||||
</DangerButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ExportButton = ({ itemId }: { itemId: string }) => (
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
aria-label="Export"
|
||||
className="export-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
TaskServiceClient.exportTaskWithId(StringRequest.create({ value: itemId })).catch((err) =>
|
||||
console.error("Failed to export task:", err),
|
||||
)
|
||||
}}>
|
||||
<div style={{ fontSize: "11px", fontWeight: 500, opacity: 1 }}>EXPORT</div>
|
||||
</VSCodeButton>
|
||||
)
|
||||
|
||||
// https://gist.github.com/evenfrost/1ba123656ded32fb7a0cd4651efd4db0
|
||||
export const highlight = (fuseSearchResult: FuseResult<any>[], highlightClassName: string = "history-item-highlight") => {
|
||||
const set = (obj: Record<string, any>, path: string, value: any) => {
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface ExtensionStateContextType extends ExtensionState {
|
||||
showAccount: boolean
|
||||
showAnnouncement: boolean
|
||||
showChatModelSelector: boolean
|
||||
expandTaskHeader: boolean
|
||||
|
||||
// Setters
|
||||
setShowAnnouncement: (value: boolean) => void
|
||||
@@ -72,6 +73,7 @@ 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
|
||||
@@ -206,6 +208,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
yoloModeToggled: false,
|
||||
customPrompt: undefined,
|
||||
useAutoCondense: false,
|
||||
autoCondenseThreshold: undefined,
|
||||
favoritedModelIds: [],
|
||||
|
||||
// NEW: Add workspace information with defaults
|
||||
@@ -213,6 +216,7 @@ 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>>({
|
||||
@@ -717,6 +721,8 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
refreshOpenRouterModels,
|
||||
onRelinquishControl,
|
||||
setUserInfo: (userInfo?: UserInfo) => setState((prevState) => ({ ...prevState, userInfo })),
|
||||
expandTaskHeader,
|
||||
setExpandTaskHeader,
|
||||
}
|
||||
|
||||
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>
|
||||
|
||||
@@ -82,14 +82,22 @@ 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