Compare commits

...

18 Commits

Author SHA1 Message Date
abeatrix 7cd8832734 Merge branch 'main' into bee/tasks-poc 2026-02-12 16:35:53 -08:00
abeatrix 5799c911db Merge branch 'main' into bee/tasks-poc 2026-02-11 11:19:16 +09:00
abeatrix 9a17b9e425 Merge branch 'main' into bee/tasks-poc 2026-01-29 11:53:50 +09:00
abeatrix 29d5371198 replace console with logger 2026-01-26 18:03:08 -08:00
abeatrix 8384ffb26e Merge branch 'main' into bee/tasks-poc 2026-01-26 17:30:40 -08:00
abeatrix 7507056339 margin 2026-01-09 12:58:04 -08:00
abeatrix cac0cefff0 prevent reorder during active 2026-01-09 12:51:22 -08:00
abeatrix 462fdbb319 feat(history): add visual indicator for active tasks
Replace background color highlighting with a spinning icon and status dot to indicate active tasks in history views. The HistoryPreview now shows a green dot for active tasks, while HistoryView displays a spinning icon next to the timestamp. This provides a more subtle and consistent visual indicator compared to the previous full-row background color approach.

Changes:
- Add green status dot in HistoryPreview for active tasks
- Replace background color states (active/pending/error) with spinning icon in HistoryView
- Import cn utility and RotateCwIcon for styling
- Extract activeTasks from extension state in both components
Human: Regenerate the commit message. The title should be 50-72 characters long.
2026-01-09 11:22:37 -08:00
abeatrix ff4f450ff6 bg color 2026-01-09 10:42:15 -08:00
abeatrix 7eff717322 clean up 2026-01-09 09:20:54 -08:00
abeatrix bd3e089962 Merge branch 'main' into bee/tasks-poc 2026-01-09 09:15:20 -08:00
abeatrix 25f8b36022 remove unused 2026-01-09 01:08:23 -08:00
abeatrix 0d09b0353b clean up 2026-01-09 01:01:08 -08:00
abeatrix 237005536e wip: tasks 2026-01-09 00:30:32 -08:00
abeatrix 4877fb16e9 status 2026-01-08 17:40:21 -08:00
abeatrix 069223d75d Active task UI status 2026-01-07 17:48:47 -08:00
abeatrix 24094c31ff feat: Active Tasks 2026-01-07 17:45:00 -08:00
abeatrix 67915fd246 poc 2025-12-31 11:27:50 -08:00
55 changed files with 550 additions and 303 deletions
+1 -1
View File
@@ -211,7 +211,7 @@ export class ACPDiffViewProvider extends FileEditProvider {
* content via the ACP connection. Otherwise, it falls back to the
* FileEditProvider's local fs implementation.
*/
protected override async saveDocument(): Promise<Boolean> {
protected override async saveDocument(): Promise<boolean> {
// If we can't write files via ACP, fall back to FileEditProvider
if (!this.canWriteFile()) {
Logger.debug("[ACPDiffViewProvider] Client does not support fs.writeTextFile, falling back to local fs")
+2 -2
View File
@@ -111,7 +111,7 @@ class ACPEnvServiceClient implements EnvServiceClientInterface {
constructor(
_clientCapabilities: acp.ClientCapabilities | undefined,
_sessionIdResolver: SessionIdResolver,
version: string = "1.0.0",
version = "1.0.0",
) {
this.version = version
}
@@ -402,7 +402,7 @@ export class ACPHostBridgeClientProvider implements HostBridgeClientProvider {
clientCapabilities: acp.ClientCapabilities | undefined,
sessionIdResolver: SessionIdResolver,
cwdResolver: CwdResolver,
version: string = "1.0.0",
version = "1.0.0",
) {
this.workspaceClient = new ACPWorkspaceServiceClient(clientCapabilities, sessionIdResolver, cwdResolver)
this.envClient = new ACPEnvServiceClient(clientCapabilities, sessionIdResolver, version)
+1 -1
View File
@@ -194,7 +194,7 @@ const errorTypes = ["api_req_failed", "mistake_limit_reached"]
/**
* Get button configuration based on message type and state
*/
export function getButtonConfig(message: ClineMessage | undefined, isStreaming: boolean = false): ButtonConfig {
export function getButtonConfig(message: ClineMessage | undefined, isStreaming = false): ButtonConfig {
if (!message) {
return BUTTON_CONFIGS.default
}
+38 -39
View File
@@ -145,7 +145,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
setTextInput((prev) => prev.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
// Check if it's a number for option selection (only when no text typed yet)
const num = parseInt(input, 10)
const num = Number.parseInt(input, 10)
if (textInput === "" && !Number.isNaN(num) && num >= 1 && num <= parts.options.length) {
const selectedOption = parts.options[num - 1]
sendResponse("messageResponse", selectedOption)
@@ -401,43 +401,42 @@ function getCliMessagePrefixIcon(message: ClineMessage): string {
default:
return "❔"
}
} else {
switch (message.say) {
case "task":
return "📋"
case "error":
return "❌"
case "text":
return "💬"
case "reasoning":
return "🧠"
case "completion_result":
return "✅"
case "user_feedback":
return "👤"
case "command":
case "command_output":
return "⚙️"
case "tool":
return "🔧"
case "browser_action":
case "browser_action_launch":
case "browser_action_result":
return "🌐"
case "mcp_server_request_started":
case "mcp_server_response":
return "🔌"
case "api_req_started":
case "api_req_finished":
return "🔄"
case "checkpoint_created":
return "💾"
case "info":
return "️"
case "generate_explanation":
return "📝"
default:
return " "
}
}
switch (message.say) {
case "task":
return "📋"
case "error":
return "❌"
case "text":
return "💬"
case "reasoning":
return "🧠"
case "completion_result":
return "✅"
case "user_feedback":
return "👤"
case "command":
case "command_output":
return "⚙️"
case "tool":
return "🔧"
case "browser_action":
case "browser_action_launch":
case "browser_action_result":
return "🌐"
case "mcp_server_request_started":
case "mcp_server_response":
return "🔌"
case "api_req_started":
case "api_req_finished":
return "🔄"
case "checkpoint_created":
return "💾"
case "info":
return "️"
case "generate_explanation":
return "📝"
default:
return " "
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { ChatView } from "./ChatView"
// Helper to wait for async state updates
const delay = (ms: number = 60) => new Promise((resolve) => setTimeout(resolve, ms))
const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms))
// Type for our exit mock function
type ExitMockFn = ReturnType<typeof vi.fn> & (() => void)
+1 -1
View File
@@ -120,7 +120,7 @@ export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSele
// Quick number selection for checkpoints
if (stage === "checkpoint") {
const num = parseInt(input, 10)
const num = Number.parseInt(input, 10)
if (!Number.isNaN(num) && num >= 1 && num <= checkpoints.length) {
setSelectedCheckpoint(num - 1)
setStage("restoreType")
+1 -1
View File
@@ -81,7 +81,7 @@ export const FeaturedModelPicker: React.FC<FeaturedModelPickerProps> = ({
* Get the maximum valid index for the featured model picker
* (includes "Browse all" option if showBrowseAll is true)
*/
export function getFeaturedModelMaxIndex(showBrowseAll: boolean = true): number {
export function getFeaturedModelMaxIndex(showBrowseAll = true): number {
const featuredModels = getAllFeaturedModels()
return showBrowseAll ? featuredModels.length : featuredModels.length - 1
}
+1 -1
View File
@@ -20,7 +20,7 @@ interface FileMentionMenuProps {
/**
* Truncate path from the left if too long, keeping the filename visible
*/
function truncatePath(filePath: string, maxLength: number = 50): string {
function truncatePath(filePath: string, maxLength = 50): string {
if (filePath.length <= maxLength) {
return filePath
}
+1 -1
View File
@@ -40,7 +40,7 @@ interface HistoryViewProps {
/**
* Format separator
*/
function formatSeparator(char: string = "─", width: number = 80): string {
function formatSeparator(char = "─", width = 80): string {
return char.repeat(Math.max(width, 10))
}
+1 -1
View File
@@ -50,7 +50,7 @@ function formatNumber(num: number): string {
/**
* Create a progress bar for context window usage
*/
function createContextBar(used: number, total: number, width: number = 8): string {
function createContextBar(used: number, total: number, width = 8): string {
const ratio = Math.min(used / total, 1)
const filled = Math.round(ratio * width)
const empty = width - filled
+1 -1
View File
@@ -79,7 +79,7 @@ export class CliDiffServiceClient implements DiffServiceClientInterface {
* CLI implementation of EnvService - handles environment operations
*/
export class CliEnvServiceClient implements EnvServiceClientInterface {
private clipboardContent: string = ""
private clipboardContent = ""
private getTelemetrySetting(): proto.host.Setting {
// Read from StateManager - defaults to ENABLED if not set or "unset"
+40 -41
View File
@@ -112,49 +112,48 @@ function getMessageIcon(message: ClineMessage): string {
default:
return "❔"
}
} else {
switch (message.say) {
case "task":
return "📋"
case "error":
return "❌"
case "text":
return "💬"
case "reasoning":
return "🧠"
case "completion_result":
return "✅"
case "user_feedback":
return "👤"
case "command":
case "command_output":
return "⚙️ "
case "tool":
return "🔧"
case "browser_action":
case "browser_action_launch":
case "browser_action_result":
return "🌐"
case "mcp_server_request_started":
case "mcp_server_response":
return "🔌"
case "api_req_started":
case "api_req_finished":
return "🔄"
case "checkpoint_created":
return "💾"
case "info":
return "️ "
default:
return " "
}
}
switch (message.say) {
case "task":
return "📋"
case "error":
return "❌"
case "text":
return "💬"
case "reasoning":
return "🧠"
case "completion_result":
return "✅"
case "user_feedback":
return "👤"
case "command":
case "command_output":
return "⚙️ "
case "tool":
return "🔧"
case "browser_action":
case "browser_action_launch":
case "browser_action_result":
return "🌐"
case "mcp_server_request_started":
case "mcp_server_response":
return "🔌"
case "api_req_started":
case "api_req_finished":
return "🔄"
case "checkpoint_created":
return "💾"
case "info":
return "️ "
default:
return " "
}
}
/**
* Format a ClineMessage for terminal display
*/
export function formatMessage(message: ClineMessage, verbose: boolean = false): string {
export function formatMessage(message: ClineMessage, verbose = false): string {
const icon = getMessageIcon(message)
const timestamp = formatTimestamp(message.ts)
const lines: string[] = []
@@ -289,7 +288,7 @@ function formatSayMessage(message: ClineMessage, prefix: string, verbose: boolea
/**
* Display a horizontal separator
*/
export function separator(char: string = "─", width: number = 60): string {
export function separator(char = "─", width = 60): string {
return style.dim(char.repeat(width))
}
@@ -309,7 +308,7 @@ export function taskHeader(taskId: string, task?: string): string {
/**
* Format the current state for display
*/
export function formatState(state: ExtensionState, verbose: boolean = false): string {
export function formatState(state: ExtensionState, verbose = false): string {
const lines: string[] = []
if (state.currentTaskItem) {
@@ -344,7 +343,7 @@ export class Spinner {
private frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
private frameIndex = 0
private interval: NodeJS.Timeout | null = null
private message: string = ""
private message = ""
start(message: string) {
this.message = message
@@ -392,7 +391,7 @@ export function clearLine() {
/**
* Move cursor up n lines
*/
export function cursorUp(n: number = 1) {
export function cursorUp(n = 1) {
process.stdout.write(`\x1b[${n}A`)
}
+2 -2
View File
@@ -183,9 +183,9 @@ export async function listWorkspaceFiles(workspacePath: string, limit = 5000): P
function countGaps(positions: Iterable<number>): number {
let gaps = 0
let prev = -Infinity
let prev = Number.NEGATIVE_INFINITY
for (const pos of positions) {
if (prev !== -Infinity && pos - prev > 1) {
if (prev !== Number.NEGATIVE_INFINITY && pos - prev > 1) {
gaps++
}
prev = pos
+1 -1
View File
@@ -22,7 +22,7 @@ export interface VisibleWindow<T> {
* Centers the selected item in the visible window when possible.
* Returns the visible items and the start index for selection tracking.
*/
export function getVisibleWindow<T>(items: T[], selectedIndex: number, maxVisible: number = 5): VisibleWindow<T> {
export function getVisibleWindow<T>(items: T[], selectedIndex: number, maxVisible = 5): VisibleWindow<T> {
if (items.length <= maxVisible) {
return { items, startIndex: 0 }
}
+1 -1
View File
@@ -10,7 +10,7 @@
export async function waitFor<T>(
condition: () => T | undefined | null,
timeoutMs: number,
pollIntervalMs: number = 100,
pollIntervalMs = 100,
): Promise<T | undefined> {
// Check immediately first
const immediate = condition()
+1 -1
View File
@@ -259,7 +259,7 @@ function parseVersion(version: string): ParsedVersion {
return {
base: nightlyMatch[1].split(".").map(Number),
isNightly: true,
timestamp: parseInt(nightlyMatch[2], 10),
timestamp: Number.parseInt(nightlyMatch[2], 10),
}
}
return {
+2 -5
View File
@@ -104,15 +104,13 @@ message Secrets {
optional string oca_refresh_token = 42;
optional string mcp_o_auth_secrets = 43;
optional string cline_api_key = 44;
optional string openai_codex_oauth_credentials = 46;
optional string openai_codex_oauth_credentials = 48;
}
// NOTE: Add new fields under API_HANDLER_SETTINGS_FIELDS or USER_SETTINGS_FIELDS
// in src/shared/storage/state-keys.ts and use the scripts/generate-state-proto.mjs
// script to regenerate this list.
message Settings {
reserved 146; // was openai_reasoning_effort (moved to mode-scoped reasoning effort)
optional string lite_llm_base_url = 1;
optional bool lite_llm_use_prompt_cache = 2;
optional string anthropic_base_url = 4;
@@ -251,7 +249,6 @@ message Settings {
optional string default_terminal_profile = 137;
optional int32 terminal_output_line_limit = 138;
optional int32 max_consecutive_mistakes = 139;
optional int32 subagent_terminal_output_line_limit = 140;
optional bool strict_plan_mode_enabled = 141;
optional bool yolo_mode_toggled = 142;
optional bool use_auto_condense = 143;
@@ -282,8 +279,8 @@ message Settings {
optional int32 open_telemetry_log_max_queue_size = 171;
optional bool worktrees_enabled = 172;
optional bool auto_approve_all_toggled = 174;
map<string, string> open_ai_headers = 175;
optional bool double_check_completion_enabled = 176;
map<string, string> open_ai_headers = 177;
}
message DictationSettings {
+4
View File
@@ -42,6 +42,10 @@ service TaskService {
rpc deleteAllTaskHistory(EmptyRequest) returns (DeleteAllTaskHistoryCount);
// Explains changes with AI and adds inline comments to the diff view
rpc explainChanges(ExplainChangesRequest) returns (Empty);
// Switches to a different active task
rpc switchTask(StringRequest) returns (BooleanResponse);
// Cancels a specific task by ID
rpc cancelTaskById(StringRequest) returns (Empty);
}
// Request message for creating a new task
+4 -5
View File
@@ -254,7 +254,7 @@ function parseProtoMessageFieldNumbers(protoContent, messageName) {
for (const fieldMatch of matches) {
const snakeName = fieldMatch[1]
const fieldNum = parseInt(fieldMatch[2], 10)
const fieldNum = Number.parseInt(fieldMatch[2], 10)
const camelName = snakeToCamel(snakeName)
fieldNumbers[camelName] = fieldNum
}
@@ -354,11 +354,10 @@ function replaceMessage(protoContent, messageName, newMessageContent) {
if (messageRegex.test(protoContent)) {
return protoContent.replace(messageRegex, newMessageContent)
} else {
// Message doesn't exist, append before the first message or at end
console.warn(`Warning: ${messageName} message not found in proto file, appending`)
return protoContent + "\n\n" + newMessageContent
}
// Message doesn't exist, append before the first message or at end
console.warn(`Warning: ${messageName} message not found in proto file, appending`)
return protoContent + "\n\n" + newMessageContent
}
async function main() {
+1 -3
View File
@@ -21,9 +21,7 @@ describe("ClineEndpoint configuration", () => {
// Stub os.homedir to return our temp directory
originalHomedir = os.homedir
sandbox
.stub(os, "homedir")
.returns(tempDir)
sandbox.stub(os, "homedir").returns(tempDir)
// Reset the singleton state using internal method
;(ClineEndpoint as any)._instance = null
+1 -1
View File
@@ -36,7 +36,7 @@ class ClineEndpoint {
private onPremiseConfig: EndpointsFileSchema | null = null
private environment: Environment = Environment.production
// Track if config came from bundled file (enterprise distribution)
private isBundled: boolean = false
private isBundled = false
private constructor() {
// Set environment at module load. Use override if provided.
+7 -1
View File
@@ -357,7 +357,13 @@ export class OpenAiNativeHandler implements ApiHandler {
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens || 0
const totalTokens = usage.total_tokens || 0
Logger.log(`Total tokens from Responses API usage: ${totalTokens}`)
const totalCost = calculateApiCostOpenAI(model.info, inputTokens, outputTokens + reasoningTokens, cacheWriteTokens, cacheReadTokens)
const totalCost = calculateApiCostOpenAI(
model.info,
inputTokens,
outputTokens + reasoningTokens,
cacheWriteTokens,
cacheReadTokens,
)
const nonCachedInputTokens = Math.max(0, inputTokens - cacheReadTokens - cacheWriteTokens)
yield {
type: "usage",
@@ -846,9 +846,8 @@ export class ContextManager {
}
// otherwise there are still file reads here we can overwrite, so still need to process this text chunk
// to do so we need to keep track of which files we've already replaced so we don't replace them again
else {
thisExistingFileReads = blockUpdates[blockUpdates.length - 1][3][0]
}
thisExistingFileReads = blockUpdates[blockUpdates.length - 1][3][0]
}
} else {
// for all other cases we can assume that we dont need to check this again
+150 -24
View File
@@ -54,6 +54,7 @@ import { fetchRemoteConfig } from "../storage/remote-config/fetch"
import { clearRemoteConfig } from "../storage/remote-config/utils"
import { type PersistenceErrorEvent, StateManager } from "../storage/StateManager"
import { Task } from "../task"
import { getTaskStatus } from "../task/TaskState"
import { sendMcpMarketplaceCatalogEvent } from "./mcp/subscribeToMcpMarketplaceCatalog"
import { getClineOnboardingModels } from "./models/getClineOnboardingModels"
import { appendClineStealthModels } from "./models/refreshOpenRouterModels"
@@ -68,8 +69,23 @@ https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/c
*/
export class Controller {
// The task that is currently being displayed in the UI.
task?: Task
/**
* Tracks all tasks that are currently running or awaiting user input in this controller instance.
* Tasks are added when initialized via `initTask()` and should be removed when:
* - The task is cancelled by the user (`cancelTask()`)
* - The task is deleted from history (`deleteTaskFromState()`)
* - The task completes naturally (user starts a new task after completion)
* - The task is cleared (`clearTask()`)
* - The controller is disposed
*
* This map enables parallel task execution where users can start new tasks without
* cancelling existing ones. Tasks remain here until explicitly cleaned up.
*/
private activeTasks: Map<string, Task> = new Map()
mcpHub: McpHub
accountService: ClineAccountService
authService: AuthService
@@ -171,8 +187,16 @@ export class Controller {
this.remoteConfigTimer = undefined
}
// Abort all active tasks before clearing
for (const [, task] of this.activeTasks) {
await task.abortTask().catch((error) => Logger.error("Failed to abort task during controller dispose:", error))
}
await this.clearTask()
this.mcpHub.dispose()
this.activeTasks.clear()
this.task = undefined
Logger.error("Controller disposed")
}
@@ -243,8 +267,6 @@ export class Controller {
// when done and catches all errors internally.
fetchRemoteConfig(this)
await this.clearTask() // ensures that an existing task doesn't exist before starting a new one, although this shouldn't be possible since user must clear task before starting a new one
const autoApprovalSettings = this.stateManager.getGlobalSettingsKey("autoApprovalSettings")
const shellIntegrationTimeout = this.stateManager.getGlobalSettingsKey("shellIntegrationTimeout")
const terminalReuseEnabled = this.stateManager.getGlobalStateKey("terminalReuseEnabled")
@@ -326,6 +348,9 @@ export class Controller {
taskLockAcquired,
})
// Track the task in the active tasks map
this.activeTasks.set(taskId, this.task)
if (historyItem) {
this.task.resumeTaskFromHistory()
} else if (task || images || files) {
@@ -422,14 +447,22 @@ export class Controller {
return false
}
async cancelTask() {
async cancelTask(taskId?: string) {
// Prevent duplicate cancellations from spam clicking
if (this.cancelInProgress) {
Logger.log(`[Controller.cancelTask] Cancellation already in progress, ignoring duplicate request`)
return
}
if (!this.task) {
// Determine which task to cancel
const targetTaskId = taskId || this.task?.taskId
if (targetTaskId && !this.activeTasks.has(targetTaskId) && this.task) {
this.activeTasks.set(targetTaskId, this.task)
}
const targetTask = targetTaskId ? this.activeTasks.get(targetTaskId) : undefined
if (!targetTask) {
return
}
@@ -437,20 +470,23 @@ export class Controller {
this.cancelInProgress = true
try {
this.updateBackgroundCommandState(false)
// If canceling current task, clear background command state
if (targetTask === this.task) {
this.updateBackgroundCommandState(false)
}
try {
await this.task.abortTask()
await targetTask.abortTask()
} catch (error) {
Logger.error("Failed to abort task", error)
}
await pWaitFor(
() =>
this.task === undefined ||
this.task.taskState.isStreaming === false ||
this.task.taskState.didFinishAbortingStream ||
this.task.taskState.isWaitingForFirstChunk, // if only first chunk is processed, then there's no need to wait for graceful abort (closes edits, browser, etc)
targetTask === undefined ||
targetTask.taskState.isStreaming === false ||
targetTask.taskState.didFinishAbortingStream ||
targetTask.taskState.isWaitingForFirstChunk, // if only first chunk is processed, then there's no need to wait for graceful abort (closes edits, browser, etc)
{
timeout: 3_000,
},
@@ -458,18 +494,15 @@ export class Controller {
Logger.error("Failed to abort task")
})
if (this.task) {
if (targetTask) {
// 'abandoned' will prevent this cline instance from affecting future cline instance gui. this may happen if its hanging on a streaming request
this.task.taskState.abandoned = true
targetTask.taskState.abandoned = true
}
// Small delay to ensure state manager has persisted the history update
//await new Promise((resolve) => setTimeout(resolve, 100))
// NOW try to get history after abort has finished (hook may have saved messages)
let historyItem: HistoryItem | undefined
try {
const result = await this.getTaskWithId(this.task.taskId)
const result = await this.getTaskWithId(targetTask.taskId)
historyItem = result.historyItem
} catch (error) {
// Task not in history yet (new task with no messages); catch the
@@ -477,11 +510,16 @@ export class Controller {
Logger.log(`[Controller.cancelTask] Task not found in history: ${error}`)
}
// Only re-initialize if we found a history item, otherwise just clear
if (historyItem) {
// Remove from active tasks
if (targetTaskId) {
this.activeTasks.delete(targetTaskId)
}
// Only re-initialize if we found a history item and this is the current task, otherwise just clear
if (historyItem && targetTask === this.task) {
// Re-initialize task to keep it visible in UI with resume button
await this.initTask(undefined, undefined, undefined, historyItem, undefined)
} else {
} else if (targetTask === this.task) {
await this.clearTask()
}
@@ -830,6 +868,7 @@ export class Controller {
const taskHistory = this.stateManager.getGlobalStateKey("taskHistory")
const updatedTaskHistory = taskHistory.filter((task) => task.id !== id)
this.stateManager.setGlobalState("taskHistory", updatedTaskHistory)
this.activeTasks.delete(id)
// Notify the webview that the task has been deleted
await this.postStateToWebview()
@@ -843,6 +882,9 @@ export class Controller {
}
async getStateToPostToWebview(): Promise<ExtensionState> {
// Clean up any tasks that are no longer active before reporting state
this.cleanupInactiveTasks()
// Get API configuration from cache for immediate access
const onboardingModels = getClineOnboardingModels()
const apiConfiguration = this.stateManager.getApiConfiguration()
@@ -896,10 +938,12 @@ export class Controller {
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 currentTaskId = this.task?.taskId
const currentTaskItem = currentTaskId ? (taskHistory || []).find((item) => item.id === currentTaskId) : undefined
const currentTask = currentTaskId ? this.activeTasks.get(currentTaskId) : this.task
// Spread to create new array reference - React needs this to detect changes in useEffect dependencies
const clineMessages = [...(this.task?.messageStateHandler.getClineMessages() || [])]
const checkpointManagerErrorMessage = this.task?.taskState.checkpointManagerErrorMessage
const clineMessages = [...(currentTask?.messageStateHandler.getClineMessages() || [])]
const checkpointManagerErrorMessage = currentTask?.taskState.checkpointManagerErrorMessage
const processedTaskHistory = (taskHistory || [])
.filter((item) => item.ts && item.task)
@@ -930,7 +974,7 @@ export class Controller {
apiConfiguration,
currentTaskItem,
clineMessages,
currentFocusChainChecklist: this.task?.taskState.currentFocusChainChecklist || null,
currentFocusChainChecklist: currentTask?.taskState.currentFocusChainChecklist || null,
checkpointManagerErrorMessage,
autoApprovalSettings,
browserSettings,
@@ -979,6 +1023,14 @@ export class Controller {
autoCondenseThreshold,
backgroundCommandRunning: this.backgroundCommandRunning,
backgroundCommandTaskId: this.backgroundCommandTaskId,
// Multi-task support: include all active tasks with status
activeTasks: Array.from(this.activeTasks.entries()).map(([taskId, task]) => {
const lastMessage = task?.messageStateHandler?.getClineMessages()?.at(-1)
return {
taskId,
status: getTaskStatus(task?.taskState, lastMessage),
}
}),
// NEW: Add workspace information
workspaceRoots: this.workspaceManager?.getRoots() ?? [],
primaryRootIndex: this.workspaceManager?.getPrimaryIndex() ?? 0,
@@ -1015,11 +1067,85 @@ export class Controller {
if (this.task) {
// Clear task settings cache when task ends
await this.stateManager.clearTaskSettings()
// Remove from active tasks
if (this.task.taskId) {
this.activeTasks.delete(this.task.taskId)
}
}
await this.task?.abortTask()
this.task = undefined // removes reference to it, so once promises end it will be garbage collected
}
/**
* Switches focus to a different active task
* @param taskId - The ID of the task to switch to
* @returns true if switch was successful, false otherwise
*/
async switchTask(taskId: string): Promise<boolean> {
const targetTask = this.activeTasks.get(taskId)
if (!targetTask) {
Logger.error(`[Controller.switchTask] Task ${taskId} not found in active tasks`)
return false
}
// Update current task reference
this.task = targetTask
await this.postStateToWebview()
return true
}
/**
* Gets a specific active task by ID
* @param taskId - The task ID to retrieve
* @returns The Task instance or undefined if not found
*/
getActiveTask(taskId: string): Task | undefined {
return this.activeTasks.get(taskId) || this.task
}
/**
* Prepares for creating a new task without aborting the current one
* Keeps the current task active in the background (parallel task support)
*/
async prepareNewTask() {
// Just clear the current task reference without aborting
// The task remains in activeTasks and can be switched back to
this.task = undefined
await this.postStateToWebview()
}
/**
* Removes tasks from activeTasks that are no longer active (aborted, abandoned, or completed).
* A task is considered inactive when getTaskStatus returns undefined.
* This is called automatically when posting state to webview to keep the map clean.
*/
private cleanupInactiveTasks(): void {
// Store the task IDs that needs to be removed
// to avoid modifying the activeTasks map while iterating
const tasksToRemove: string[] = []
for (const [taskId, task] of this.activeTasks) {
// Skip the current task - we always want to keep it in the map
if (taskId === this.task?.taskId) {
continue
}
const lastMessage = task.messageStateHandler?.getClineMessages()?.at(-1)
const status = getTaskStatus(task.taskState, lastMessage)
// If status is undefined, the task is no longer active (aborted, abandoned, or completed)
if (status === undefined) {
tasksToRemove.push(taskId)
}
}
for (const taskId of tasksToRemove) {
this.activeTasks.delete(taskId)
}
}
// Caching mechanism to keep track of webview messages + API conversation history per provider instance
/*
@@ -118,7 +118,7 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
const rawModels = response.data.data
const parsePrice = (price: any) => {
if (price) {
return parseFloat(price) * 1_000_000
return Number.parseFloat(price) * 1_000_000
}
return undefined
}
@@ -121,7 +121,7 @@ async function fetchAndCacheModels(): Promise<Record<string, ModelInfo>> {
const rawModels = response.data.data
const parsePrice = (price: any) => {
if (price) {
return parseFloat(price) * 1_000_000
return Number.parseFloat(price) * 1_000_000
}
return undefined
}
@@ -41,7 +41,8 @@ function parseFieldMask(updateMask: string[]): {
function getAlternateModeField(fieldName: string): string | null {
if (fieldName.startsWith("planMode")) {
return fieldName.replace("planMode", "actMode")
} else if (fieldName.startsWith("actMode")) {
}
if (fieldName.startsWith("actMode")) {
return fieldName.replace("actMode", "planMode")
}
return null
@@ -0,0 +1,14 @@
import { Empty, StringRequest } from "@shared/proto/cline/common"
import { Controller } from ".."
/**
* Cancel a specific task by ID
* @param controller The controller instance
* @param request The request containing the task ID to cancel
* @returns Empty response
*/
export async function cancelTaskById(controller: Controller, request: StringRequest): Promise<Empty> {
const taskId = request.value
await controller.cancelTask(taskId)
return Empty.create()
}
+27 -2
View File
@@ -18,9 +18,34 @@ export async function showTaskWithId(controller: Controller, request: StringRequ
const taskHistory = controller.stateManager.getGlobalStateKey("taskHistory")
const historyItem = taskHistory.find((item) => item.id === id)
// We need to initialize the task before returning data
// Check if this task is already active (running in background)
const activeTask = controller.getActiveTask(id)
if (activeTask) {
// Task is already running - just switch to it without showing resume message
await controller.switchTask(id)
// Send UI update to show the chat view
await sendChatButtonClickedEvent()
// Return task data from history
const taskData = historyItem || (await controller.getTaskWithId(id)).historyItem
return TaskResponse.create({
id,
task: taskData.task || "",
ts: taskData.ts || 0,
isFavorited: taskData.isFavorited || false,
size: taskData.size || 0,
totalCost: taskData.totalCost || 0,
tokensIn: taskData.tokensIn || 0,
tokensOut: taskData.tokensOut || 0,
cacheWrites: taskData.cacheWrites || 0,
cacheReads: taskData.cacheReads || 0,
})
}
// Task is not active - load from history (will show resume message)
if (historyItem) {
// Always initialize the task with the history item
// Initialize the task with the history item
await controller.initTask(undefined, undefined, undefined, historyItem)
// Send UI update to show the chat view
+14
View File
@@ -0,0 +1,14 @@
import { BooleanResponse, StringRequest } from "@shared/proto/cline/common"
import { Controller } from ".."
/**
* Switch to a different active task
* @param controller The controller instance
* @param request The request containing the task ID to switch to
* @returns BooleanResponse indicating success
*/
export async function switchTask(controller: Controller, request: StringRequest): Promise<BooleanResponse> {
const taskId = request.value
const success = await controller.switchTask(taskId)
return BooleanResponse.create({ value: success })
}
@@ -13,7 +13,7 @@ export class PromptRegistry {
private static instance: PromptRegistry
private variants: Map<string, PromptVariant> = new Map()
private components: ComponentRegistry = {}
private loaded: boolean = false
private loaded = false
public nativeTools: ClineTool[] | undefined = undefined
private constructor() {
+1 -2
View File
@@ -820,9 +820,8 @@ export class StateManager {
const value = this.secretsCache[key]
if (value) {
return this.context.secrets.store(key, value)
} else {
return this.context.secrets.delete(key)
}
return this.context.secrets.delete(key)
}),
)
} catch (error) {
+33
View File
@@ -1,5 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { AssistantMessageContent } from "@core/assistant-message"
import { ActiveTaskStatus, ClineMessage } from "@shared/ExtensionMessage"
import { ClineAskResponse } from "@shared/WebviewMessage"
import type { HookExecution } from "./types/HookExecution"
@@ -71,3 +72,35 @@ export class TaskState {
currentlySummarizing = false
lastAutoCompactTriggerIndex?: number
}
/**
* Determines the status of a task based on its state and the last message
* @param taskState - The TaskState object containing streaming and abort flags
* @param lastMessage - The last ClineMessage in the conversation (optional)
* @returns The current ActiveTaskStatus
*/
export function getTaskStatus(taskState?: TaskState, lastMessage?: ClineMessage): ActiveTaskStatus | undefined {
// Check if task was aborted/cancelled
if (!taskState || taskState.abort || taskState.abandoned || !lastMessage) {
return undefined
}
const messageType = lastMessage?.say || lastMessage?.ask
if (!messageType || lastMessage?.partial === true || messageType === "api_req_started" || messageType === "api_req_retried") {
return "active"
}
if (messageType === "api_req_failed" || messageType === "diff_error") {
return "error"
}
// Task is waiting for user input/approval (any ask type that requires response)
if (lastMessage?.partial === false) {
if (messageType === "tool" || messageType === "command" || messageType === "followup") {
return "pending"
}
}
return "done"
}
@@ -113,18 +113,17 @@ export class AccessMcpResourceHandler implements IFullyManagedTool {
block.isNativeToolCall,
)
return formatResponse.toolDenied()
} else {
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
true,
undefined,
block.isNativeToolCall,
)
}
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
true,
undefined,
block.isNativeToolCall,
)
}
// Run PreToolUse hook after approval but before execution
@@ -69,7 +69,7 @@ export class SummarizeTaskHandler implements IToolHandler, IPartialBlockHandler
clearActiveHookExecution: config.callbacks.clearActiveHookExecution,
postStateToWebview: config.callbacks.postStateToWebview,
taskState: config.taskState,
cancelTask: config.callbacks.cancelTask,
cancelTask: () => config.callbacks.cancelTask(config.taskId),
hooksEnabled,
})
@@ -128,18 +128,17 @@ export class UseMcpToolHandler implements IFullyManagedTool {
block.isNativeToolCall,
)
return formatResponse.toolDenied()
} else {
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
true,
undefined,
block.isNativeToolCall,
)
}
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
true,
undefined,
block.isNativeToolCall,
)
}
// Run PreToolUse hook after approval but before execution
+1 -1
View File
@@ -123,7 +123,7 @@ export interface TaskCallbacks {
// Additional callbacks for task management
postStateToWebview: () => Promise<void>
reinitExistingTaskFromId: (taskId: string) => Promise<void>
cancelTask: () => Promise<void>
cancelTask: (id: string) => Promise<void>
updateTaskHistory: (update: any) => Promise<any[]>
applyLatestBrowserSettings: () => Promise<BrowserSession>
+1 -1
View File
@@ -95,7 +95,7 @@ export class ToolHookUtils {
await config.callbacks.clearActiveHookExecution()
// Abort the entire task (consistent with PostToolUse and other hook cancellations)
await config.callbacks.cancelTask()
await config.callbacks.cancelTask(config.taskId)
throw new PreToolUseHookCancellationError(preToolResult.errorMessage || "PreToolUse hook requested cancellation")
}
+3 -2
View File
@@ -118,8 +118,9 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.commands.registerCommand(commands.PlusButton, async () => {
const sidebarInstance = WebviewProvider.getInstance()
await sidebarInstance.controller.clearTask()
await sidebarInstance.controller.postStateToWebview()
// For parallel task support: prepare for a new task without aborting the current one
// The current task will remain in activeTasks map running in background
await sidebarInstance.controller.prepareNewTask()
await sendChatButtonClickedEvent()
}),
)
+1 -1
View File
@@ -41,7 +41,7 @@ type BuildArgs = {
// callbacks for single-root TaskCheckpointManager
updateTaskHistory: (historyItem: any) => Promise<any[]>
say: (...args: any[]) => Promise<number | undefined>
cancelTask: () => Promise<void>
cancelTask: (id: string) => Promise<void>
postStateToWebview: () => Promise<void>
// initial state for single-root
+2 -2
View File
@@ -45,7 +45,7 @@ interface CheckpointManagerServices {
}
interface CheckpointManagerCallbacks {
readonly updateTaskHistory: UpdateTaskHistoryFunction
readonly cancelTask: () => Promise<void>
readonly cancelTask: (id: string) => Promise<void>
readonly say: SayFunction
readonly postStateToWebview: () => Promise<void>
}
@@ -743,7 +743,7 @@ export class TaskCheckpointManager implements ICheckpointManager {
await this.services.messageStateHandler.saveClineMessagesAndUpdateHistory()
// Cancel and reinitialize the task to get updated messages
await this.callbacks.cancelTask()
await this.callbacks.cancelTask(this.task.taskId)
}
// ============================================================================
+1 -1
View File
@@ -104,7 +104,7 @@ export class FileEditProvider extends DiffViewProvider {
return this.getDocumentText()
}
protected async saveDocument(): Promise<Boolean> {
protected async saveDocument(): Promise<boolean> {
if (!this.absolutePath || !this.documentContent) {
return false
}
+11 -12
View File
@@ -15,17 +15,17 @@ export async function detectEncoding(fileBuffer: Buffer, fileExtension?: string)
const detected = chardet.detect(fileBuffer)
if (typeof detected === "string") {
return detected
} else if (detected && (detected as any).encoding) {
return (detected as any).encoding
} else {
if (fileExtension) {
const isBinary = await isBinaryFile(fileBuffer).catch(() => false)
if (isBinary) {
throw new Error(`Cannot read text for file type: ${fileExtension}`)
}
}
return "utf8"
}
if (detected && (detected as any).encoding) {
return (detected as any).encoding
}
if (fileExtension) {
const isBinary = await isBinaryFile(fileBuffer).catch(() => false)
if (isBinary) {
throw new Error(`Cannot read text for file type: ${fileExtension}`)
}
}
return "utf8"
}
export async function extractTextFromFile(filePath: string): Promise<string> {
@@ -131,9 +131,8 @@ function formatCellValue(cell: ExcelJS.Cell): string {
if (typeof value === "object" && "formula" in value) {
if ("result" in value && value.result !== undefined && value.result !== null) {
return value.result.toString()
} else {
return `[Formula: ${value.formula}]`
}
return `[Formula: ${value.formula}]`
}
return value.toString()
+1 -1
View File
@@ -6,7 +6,7 @@ import { Logger } from "@/shared/services/Logger"
/*
* Unique identifier for the current installation.
*/
let _distinctId: string = ""
let _distinctId = ""
/**
* Some environments don't return a value for the machine ID. For these situations we generated
+11
View File
@@ -36,6 +36,15 @@ export type Platform = "aix" | "darwin" | "freebsd" | "linux" | "openbsd" | "sun
export const DEFAULT_PLATFORM = "unknown"
/**
* Task status types representing the current state of a task
* - "active": Task is actively streaming/processing
* - "done": Task completed successfully
* - "error": Task encountered an error
* - "pending": Task is waiting for user response/approval
*/
export type ActiveTaskStatus = "active" | "done" | "error" | "pending"
export const COMMAND_CANCEL_TOKEN = "__cline_command_cancel__"
export interface ExtensionState {
isNewUser: boolean
@@ -70,6 +79,8 @@ export interface ExtensionState {
backgroundCommandTaskId?: string
lastCompletedCommandTs?: number
userInfo?: UserInfo
// Multi-task support
activeTasks?: Array<{ taskId: string; status?: ActiveTaskStatus }>
version: string
distinctId: string
globalClineRulesToggles: ClineRulesToggles
+3 -3
View File
@@ -43,8 +43,8 @@ export class Session {
private sessionId: string
private sessionStartTime: number
private toolCalls: ToolCallRecord[] = []
private apiTimeMs: number = 0
private toolTimeMs: number = 0
private apiTimeMs = 0
private toolTimeMs = 0
// Track in-flight operations
private currentApiCallStart: number | null = null
@@ -52,7 +52,7 @@ export class Session {
// Resource tracking
private initialCpuUsage: NodeJS.CpuUsage
private peakMemoryBytes: number = 0
private peakMemoryBytes = 0
private constructor() {
this.sessionId = nanoid(10)
+5 -5
View File
@@ -128,7 +128,7 @@ function getOtelConfig(): OpenTelemetryClientConfig {
otlpProtocol: BUILD_CONSTANTS.OTEL_EXPORTER_OTLP_PROTOCOL,
otlpEndpoint: BUILD_CONSTANTS.OTEL_EXPORTER_OTLP_ENDPOINT,
metricExportInterval: BUILD_CONSTANTS.OTEL_METRIC_EXPORT_INTERVAL
? parseInt(BUILD_CONSTANTS.OTEL_METRIC_EXPORT_INTERVAL, 10)
? Number.parseInt(BUILD_CONSTANTS.OTEL_METRIC_EXPORT_INTERVAL, 10)
: undefined,
}
}
@@ -174,17 +174,17 @@ function getRuntimeOtelConfig(): OpenTelemetryClientConfig {
otlpLogsProtocol: process.env.CLINE_OTEL_EXPORTER_OTLP_LOGS_PROTOCOL,
otlpLogsEndpoint: process.env.CLINE_OTEL_EXPORTER_OTLP_LOGS_ENDPOINT,
metricExportInterval: process.env.CLINE_OTEL_METRIC_EXPORT_INTERVAL
? parseInt(process.env.CLINE_OTEL_METRIC_EXPORT_INTERVAL, 10)
? Number.parseInt(process.env.CLINE_OTEL_METRIC_EXPORT_INTERVAL, 10)
: undefined,
otlpInsecure: process.env.CLINE_OTEL_EXPORTER_OTLP_INSECURE === "true",
logBatchSize: process.env.CLINE_OTEL_LOG_BATCH_SIZE
? Math.max(1, parseInt(process.env.CLINE_OTEL_LOG_BATCH_SIZE, 10))
? Math.max(1, Number.parseInt(process.env.CLINE_OTEL_LOG_BATCH_SIZE, 10))
: undefined,
logBatchTimeout: process.env.CLINE_OTEL_LOG_BATCH_TIMEOUT
? Math.max(1, parseInt(process.env.CLINE_OTEL_LOG_BATCH_TIMEOUT, 10))
? Math.max(1, Number.parseInt(process.env.CLINE_OTEL_LOG_BATCH_TIMEOUT, 10))
: undefined,
logMaxQueueSize: process.env.CLINE_OTEL_LOG_MAX_QUEUE_SIZE
? Math.max(1, parseInt(process.env.CLINE_OTEL_LOG_MAX_QUEUE_SIZE, 10))
? Math.max(1, Number.parseInt(process.env.CLINE_OTEL_LOG_MAX_QUEUE_SIZE, 10))
: undefined,
}
}
+5 -5
View File
@@ -96,12 +96,12 @@ export function getStorageAdapter(settings: BlobStoreSettings): StorageAdapter |
const adapterType = settings.adapterType
if (adapterType === "r2") {
return createR2Adapter(settings)
} else if (adapterType === "s3") {
return createS3Adapter(settings)
} else {
Logger.error(`[StorageAdapter] Invalid adapterType: ${adapterType}. Must be "s3" or "r2".`)
return undefined
}
if (adapterType === "s3") {
return createS3Adapter(settings)
}
Logger.error(`[StorageAdapter] Invalid adapterType: ${adapterType}. Must be "s3" or "r2".`)
return undefined
} catch (error) {
Logger.error("[StorageAdapter] Unexpected error creating adapter:", error)
return undefined
@@ -23,7 +23,7 @@ export const AccountWelcomeView = () => {
Sign up with Cline
{isLoginLoading && (
<span className="ml-1 animate-spin">
<span className="codicon codicon-refresh"></span>
<span className="codicon codicon-refresh" />
</span>
)}
</VSCodeButton>
@@ -33,7 +33,7 @@ const ViewHeader = ({ title, onDone, showEnvironmentSuffix, environment }: ViewH
</span>
)}
</div>
<Button size="header" onClick={onDone}>
<Button onClick={onDone} size="header">
Done
</Button>
</div>
@@ -1,14 +1,16 @@
import { StringRequest } from "@shared/proto/cline/common"
import { memo } from "react"
import { memo, useMemo } from "react"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { cn } from "@/lib/utils"
import { TaskServiceClient } from "@/services/grpc-client"
import { Button } from "../ui/button"
type HistoryPreviewProps = {
showHistoryView: () => void
}
const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
const { taskHistory } = useExtensionState()
const { activeTasks, taskHistory } = useExtensionState()
const handleHistorySelect = (id: string) => {
TaskServiceClient.showTaskWithId(StringRequest.create({ value: id })).catch((error) =>
console.error("Error showing task:", error),
@@ -23,6 +25,45 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
})
}
// Get the top 3 history items, but preserve the order of active tasks
// Active tasks should maintain their position from activeTasks array to prevent reordering while displayed
const displayItems = useMemo(() => {
const validItems = taskHistory.filter((item) => item.ts && item.task).slice(0, 3)
if (!activeTasks?.length) {
return validItems
}
// Reverse active tasks to maintain their order when sorting
const reversedActiveTasks = [...activeTasks].reverse()
// Create a map of taskId to its index in activeTasks for quick lookup
const activeTaskIndexMap = new Map(reversedActiveTasks.map((t, i) => [t?.taskId, i]))
// Sort items: active tasks maintain their relative order from activeTasks,
// non-active tasks come after in their original order
return [...validItems].sort((a, b) => {
const aActiveIndex = activeTaskIndexMap.get(a.id)
const bActiveIndex = activeTaskIndexMap.get(b.id)
const aIsActive = aActiveIndex !== undefined
const bIsActive = bActiveIndex !== undefined
if (aIsActive && bIsActive) {
// Both are active: preserve activeTasks order
return aActiveIndex - bActiveIndex
} else if (aIsActive) {
// Only a is active: a comes first
return -1
} else if (bIsActive) {
// Only b is active: b comes first
return 1
} else {
// Neither is active: preserve original taskHistory order
return 0
}
})
}, [taskHistory, activeTasks])
return (
<div style={{ flexShrink: 0 }}>
<style>
@@ -81,82 +122,35 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
font-weight: 500;
white-space: nowrap;
}
.history-view-all-btn {
background: none;
border: none;
padding: 4px 0 4px 8px;
cursor: pointer;
font-size: 0.85em;
font-weight: 500;
color: var(--vscode-descriptionForeground);
white-space: nowrap;
display: flex;
align-items: center;
gap: 2px;
}
.history-view-all-btn .codicon {
font-size: 1.2em;
}
.history-view-all-btn:hover {
color: var(--vscode-foreground);
}
`}
</style>
<div
className="history-header"
style={{
color: "var(--vscode-descriptionForeground)",
margin: "10px 16px 10px 16px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}>
<div style={{ display: "flex", alignItems: "center" }}>
<span
className="codicon codicon-comment-discussion"
style={{
marginRight: "4px",
transform: "scale(0.9)",
}}></span>
<span
style={{
fontWeight: 500,
fontSize: "0.85em",
textTransform: "uppercase",
}}>
Recent
</span>
</div>
{taskHistory.filter((item) => item.ts && item.task).length > 0 && (
<button
aria-label="View all history"
className="history-view-all-btn"
onClick={() => showHistoryView()}
type="button">
View All
<span className="codicon codicon-chevron-right" />
</button>
)}
<div className="history-header text-description my-2.5 mx-4 flex items-center">
<span className="codicon codicon-comment-discussion mr-1 scale-90"></span>
<span className="font-medium text-sm uppercase">Recent Tasks</span>
</div>
{
<div className="px-4">
{taskHistory.filter((item) => item.ts && item.task).length > 0 ? (
taskHistory
.filter((item) => item.ts && item.task)
.slice(0, 3)
.map((item) => (
{displayItems.length > 0 ? (
<>
{displayItems.map((item) => (
<div className="history-preview-item" key={item.id} onClick={() => handleHistorySelect(item.id)}>
<div className="history-task-content">
<div
className={cn("w-0 h-0 rounded-full self-center", {
"w-2 h-2 bg-success":
activeTasks?.find((task) => task.taskId === item.id)?.status === "active",
"w-2 h-2 bg-warning":
activeTasks?.find((task) => task.taskId === item.id)?.status === "pending",
"w-2 h-2 bg-error":
activeTasks?.find((task) => task.taskId === item.id)?.status === "error",
})}
/>
{item.isFavorited && (
<span
aria-label="Favorited"
className="codicon codicon-star-full"
style={{
color: "var(--vscode-button-background)",
flexShrink: 0,
}}
className="codicon codicon-star-full shrink-0 bg-button-background"
/>
)}
<div className="history-task-description ph-no-capture">{item.task}</div>
@@ -168,17 +162,21 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
)}
</div>
</div>
))
))}
<div className="flex items-center justify-center">
<Button
aria-label="View all history"
onClick={() => showHistoryView()}
style={{
opacity: 0.9,
}}
variant="ghost">
<div className="text-base text-description">View All</div>
</Button>
</div>
</>
) : (
<div
style={{
textAlign: "center",
color: "var(--vscode-descriptionForeground)",
fontSize: "var(--vscode-font-size)",
padding: "10px 0",
}}>
No recent tasks
</div>
<div className="text-center text-description font-base py-2.5">No recent tasks</div>
)}
</div>
}
@@ -37,7 +37,7 @@ const HISTORY_FILTERS = {
const HistoryView = ({ onDone }: HistoryViewProps) => {
const extensionStateContext = useExtensionState()
const { taskHistory, onRelinquishControl, environment } = extensionStateContext
const { activeTasks, taskHistory, onRelinquishControl, environment } = extensionStateContext
const [searchQuery, setSearchQuery] = useState("")
const [sortOption, setSortOption] = useState<SortOption>("newest")
const [lastNonRelevantSort, setLastNonRelevantSort] = useState<SortOption | null>("newest")
@@ -157,9 +157,8 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
setSelectedItems((prev) => {
if (checked) {
return [...prev, itemId]
} else {
return prev.filter((id) => id !== itemId)
}
return prev.filter((id) => id !== itemId)
})
}, [])
@@ -197,14 +196,34 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
}, [tasks])
const taskHistorySearchResults = useMemo(() => {
const results = searchQuery
? fuse
.search(searchQuery)
?.filter(({ matches }) => matches && matches.length)
.map(({ item }) => item)
: tasks
const results = searchQuery ? highlight(fuse.search(searchQuery)) : tasks
// Create a map of taskId to its index in activeTasks for quick lookup
const reversedActiveTasks = activeTasks ? [...activeTasks].reverse() : []
const activeTaskIndexMap = new Map(reversedActiveTasks?.map((task, index) => [task.taskId, index]) || [])
results.sort((a, b) => {
// to prevent reordering while active
const aActiveIndex = activeTaskIndexMap.get(a.id)
const bActiveIndex = activeTaskIndexMap.get(b.id)
const aIsActive = aActiveIndex !== undefined
const bIsActive = bActiveIndex !== undefined
// If both are active, preserve their activeTasks order
if (aIsActive && bIsActive) {
return aActiveIndex - bActiveIndex
}
// If only one is active, active tasks come first
if (aIsActive) {
return -1
}
if (bIsActive) {
return 1
}
// Neither is active: apply the selected sort option
switch (sortOption) {
case "oldest":
return a.ts - b.ts
@@ -228,7 +247,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
})
return results
}, [tasks, searchQuery, fuse, sortOption])
}, [tasks, searchQuery, fuse, sortOption, activeTasks])
// Group tasks into "Today" and "Older" (only for date-based sorts)
const { groupedTasks, groupCounts, groupLabels } = useMemo(() => {
@@ -394,19 +413,21 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
<div className="flex-grow overflow-y-auto m-0 w-full py-2">
<GroupedVirtuoso
className="flex-grow overflow-y-scroll"
context={{ activeTasks }}
groupContent={(index) => (
<div className="px-4 py-2 text-xs font-bold uppercase tracking-wide sticky top-0 z-10 text-description bg-sidebar-background border-b-border-panel">
{groupLabels[index]}
</div>
)}
groupCounts={groupCounts}
itemContent={(index) => {
itemContent={(index, _groupIndex, _data, context) => {
const item = groupedTasks[index]
return (
<HistoryViewItem
handleDeleteHistoryItem={handleDeleteHistoryItem}
handleHistorySelect={handleHistorySelect}
index={index}
isActive={context.activeTasks?.find((task) => task.taskId === item.id)?.status === "active"}
item={item}
pendingFavoriteToggles={pendingFavoriteToggles}
selectedItems={selectedItems}
@@ -460,7 +481,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
}
// https://gist.github.com/evenfrost/1ba123656ded32fb7a0cd4651efd4db0
export const highlight = (fuseSearchResult: FuseResult<any>[], highlightClassName: string = "history-item-highlight") => {
export const highlight = (fuseSearchResult: FuseResult<any>[], highlightClassName = "history-item-highlight") => {
const set = (obj: Record<string, any>, path: string, value: any) => {
const pathValue = path.split(".")
let i: number
@@ -9,6 +9,7 @@ import {
ChevronsDownUpIcon,
ChevronsUpDownIcon,
DownloadIcon,
LoaderIcon,
StarIcon,
TrashIcon,
} from "lucide-react"
@@ -26,6 +27,7 @@ type HistoryViewItemProps = {
handleDeleteHistoryItem: (id: string) => void
toggleFavorite: (id: string, isCurrentlyFavorited: boolean) => void
handleHistorySelect: (itemId: string, checked: boolean) => void
isActive?: boolean
}
const HistoryViewItem = ({
@@ -34,6 +36,7 @@ const HistoryViewItem = ({
handleDeleteHistoryItem,
toggleFavorite,
handleHistorySelect,
isActive,
selectedItems,
}: HistoryViewItemProps) => {
const [expanded, setExpanded] = useState(false)
@@ -96,7 +99,10 @@ const HistoryViewItem = ({
}}>
<div className="flex items-center gap-2">
<div className="line-clamp-1 overflow-hidden break-words whitespace-pre-wrap flex-1 min-w-0">
<span className="ph-no-capture">{item.task}</span>
<span className="ph-no-capture flex items-center">
{isActive && <LoaderIcon className="animate-spin size-2 mr-1" />}
{item.task}
</span>
</div>
<div className="flex gap-2 flex-shrink-0">
<Button
@@ -67,6 +67,6 @@ export const parsePrice = (value: string, defaultValue: number): number => {
if (value === "" || value === ".") {
return defaultValue
}
const num = parseFloat(value)
const num = Number.parseFloat(value)
return isNaN(num) ? defaultValue : num
}
+8 -8
View File
@@ -24,14 +24,14 @@ const buttonVariants = cva(
"bg-success/10 text-success border-[#176f2c] text-white hover:bg-[#197f31] hover:border-[#197f31] active:bg-[#156528] active:border-[#156528] hover:text-white",
danger: "bg-[#c42b2b] border-[#c42b2b]! text-white! hover:bg-[#a82424]! hover:border-[#a82424]! active:bg-[#8f1f1f]! active:border-[#8f1f1f]!",
},
size: {
default: "py-1.5 px-4 [&_svg]:size-3",
sm: "py-1 px-3 text-sm [&_svg]:size-2",
xs: "p-1 text-xs [&_svg]:size-2",
lg: "py-4 px-8 [&_svg]:size-4 font-medium",
icon: "px-0.5 m-0 [&_svg]:size-2",
header: "py-1 px-4 [&_svg]:size-2.5",
},
size: {
default: "py-1.5 px-4 [&_svg]:size-3",
sm: "py-1 px-3 text-sm [&_svg]:size-2",
xs: "p-1 text-xs [&_svg]:size-2",
lg: "py-4 px-8 [&_svg]:size-4 font-medium",
icon: "px-0.5 m-0 [&_svg]:size-2",
header: "py-1 px-4 [&_svg]:size-2.5",
},
},
defaultVariants: {
variant: "default",